extodan-agentsync 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- extodan_agentsync-0.1.0/.gitignore +53 -0
- extodan_agentsync-0.1.0/CHANGELOG.md +32 -0
- extodan_agentsync-0.1.0/LICENSE +21 -0
- extodan_agentsync-0.1.0/PKG-INFO +197 -0
- extodan_agentsync-0.1.0/README.md +164 -0
- extodan_agentsync-0.1.0/assets/README.md +40 -0
- extodan_agentsync-0.1.0/assets/agentsync.gif +0 -0
- extodan_agentsync-0.1.0/assets/agentsync.tape +28 -0
- extodan_agentsync-0.1.0/examples/__init__.py +0 -0
- extodan_agentsync-0.1.0/examples/langgraph_demo.py +156 -0
- extodan_agentsync-0.1.0/pyproject.toml +82 -0
- extodan_agentsync-0.1.0/src/agentsync/__init__.py +17 -0
- extodan_agentsync-0.1.0/src/agentsync/__main__.py +55 -0
- extodan_agentsync-0.1.0/src/agentsync/demo/langgraph_demo.py +232 -0
- extodan_agentsync-0.1.0/src/agentsync/harness.py +252 -0
- extodan_agentsync-0.1.0/src/agentsync/models.py +251 -0
- extodan_agentsync-0.1.0/src/agentsync/py.typed +0 -0
- extodan_agentsync-0.1.0/src/agentsync/repro.py +146 -0
- extodan_agentsync-0.1.0/src/agentsync/store.py +223 -0
- extodan_agentsync-0.1.0/src/agentsync/strategies/__init__.py +43 -0
- extodan_agentsync-0.1.0/src/agentsync/strategies/crdt.py +275 -0
- extodan_agentsync-0.1.0/src/agentsync/strategies/lww.py +115 -0
- extodan_agentsync-0.1.0/src/agentsync/strategies/transactional.py +209 -0
- extodan_agentsync-0.1.0/src/agentsync/table.py +54 -0
- extodan_agentsync-0.1.0/src/agentsync/workloads/__init__.py +102 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# Byte-compiled / optimized / DLL files
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
|
|
6
|
+
# C extensions
|
|
7
|
+
*.so
|
|
8
|
+
|
|
9
|
+
# Distribution / packaging
|
|
10
|
+
build/
|
|
11
|
+
dist/
|
|
12
|
+
*.egg-info/
|
|
13
|
+
*.egg
|
|
14
|
+
.eggs/
|
|
15
|
+
develop-eggs/
|
|
16
|
+
sdist/
|
|
17
|
+
wheels/
|
|
18
|
+
pip-wheel-metadata/
|
|
19
|
+
share/python-wheels/
|
|
20
|
+
|
|
21
|
+
# Virtual environments
|
|
22
|
+
.venv/
|
|
23
|
+
venv/
|
|
24
|
+
env/
|
|
25
|
+
ENV/
|
|
26
|
+
|
|
27
|
+
# Test / coverage / cache
|
|
28
|
+
.pytest_cache/
|
|
29
|
+
.coverage
|
|
30
|
+
.coverage.*
|
|
31
|
+
htmlcov/
|
|
32
|
+
.tox/
|
|
33
|
+
.nox/
|
|
34
|
+
.ruff_cache/
|
|
35
|
+
.mypy_cache/
|
|
36
|
+
coverage.xml
|
|
37
|
+
|
|
38
|
+
# Type-check artifact
|
|
39
|
+
*.typed-cache
|
|
40
|
+
|
|
41
|
+
# Bench artifacts
|
|
42
|
+
bench-results/
|
|
43
|
+
*.bench.json
|
|
44
|
+
|
|
45
|
+
# IDE / editor
|
|
46
|
+
.idea/
|
|
47
|
+
.vscode/
|
|
48
|
+
*.swp
|
|
49
|
+
*.swo
|
|
50
|
+
|
|
51
|
+
# OS
|
|
52
|
+
.DS_Store
|
|
53
|
+
Thumbs.db
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [0.1.0] - 2026-06-26
|
|
9
|
+
|
|
10
|
+
First public (alpha) release. The benchmark is the product; this version adds
|
|
11
|
+
the drop-in LangGraph store on top of it.
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
- `SyncedStore` — a drop-in for langgraph's `InMemoryStore` (`compile(store=SyncedStore())`)
|
|
15
|
+
that CRDT-merges concurrent writes instead of silently dropping them. Lists
|
|
16
|
+
union, text concatenates, nested dicts deep-merge; every write is attributed
|
|
17
|
+
via `store.acting_as(agent_id)`.
|
|
18
|
+
- Semantic-conflict escalation: when two agents set the same scalar to different
|
|
19
|
+
values, the conflict is recorded (both contenders attributed) and NOT silently
|
|
20
|
+
resolved. Drain via `store.on_escalation(cb)` or `store.escalations()`.
|
|
21
|
+
- Three-way benchmark harness (`lww` / `transactional` / `crdt`) behind one
|
|
22
|
+
common `MergeStrategy` interface, with two workloads (clean-merge and
|
|
23
|
+
semantic-conflict) and an honest `outcome` column distinguishing
|
|
24
|
+
`corrupted` / `auto_merged` / `resolved` / `escalated`.
|
|
25
|
+
- `/examples/langgraph_demo.py` — the landing-page demo: same two-agent graph on
|
|
26
|
+
`InMemoryStore` vs `SyncedStore`, showing silent write-loss vs merge +
|
|
27
|
+
attribution + escalation.
|
|
28
|
+
- `py.typed` marker (PEP 561); MIT license; CI on Python 3.10/3.11/3.12.
|
|
29
|
+
|
|
30
|
+
### Verified
|
|
31
|
+
- langgraph 1.2.6 store API: parallel-node `put`s produce two separate `batch()`
|
|
32
|
+
calls; `InMemoryStore` silently clobbers the first, `SyncedStore` merges.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 extodan
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: extodan-agentsync
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Drop-in LangGraph store that merges concurrent writes instead of silently dropping them.
|
|
5
|
+
Project-URL: Homepage, https://github.com/Extodan-Corp/AgentSync
|
|
6
|
+
Project-URL: Repository, https://github.com/Extodan-Corp/AgentSync
|
|
7
|
+
Project-URL: Issues, https://github.com/Extodan-Corp/AgentSync/issues
|
|
8
|
+
Author: extodan
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: agent-memory,agents,concurrency,crdt,langgraph,multi-agent
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: Implementation :: CPython
|
|
21
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
22
|
+
Classifier: Typing :: Typed
|
|
23
|
+
Requires-Python: >=3.10
|
|
24
|
+
Requires-Dist: langgraph<2,>=1.2
|
|
25
|
+
Requires-Dist: loro>=1.0
|
|
26
|
+
Provides-Extra: bench
|
|
27
|
+
Requires-Dist: psutil>=6.0; extra == 'bench'
|
|
28
|
+
Provides-Extra: demo
|
|
29
|
+
Requires-Dist: langgraph<2,>=1.2; extra == 'demo'
|
|
30
|
+
Provides-Extra: test
|
|
31
|
+
Requires-Dist: pytest>=8.0; extra == 'test'
|
|
32
|
+
Description-Content-Type: text/markdown
|
|
33
|
+
|
|
34
|
+
# agentsync
|
|
35
|
+
|
|
36
|
+
[](https://pypi.org/project/extodan-agentsync/)
|
|
37
|
+
[](https://github.com/Extodan-Corp/AgentSync/actions/workflows/ci.yml)
|
|
38
|
+
[](https://opensource.org/licenses/MIT)
|
|
39
|
+
[](https://www.python.org/downloads/)
|
|
40
|
+
|
|
41
|
+

|
|
42
|
+
|
|
43
|
+
**Drop-in LangGraph store that merges concurrent agent writes instead of silently dropping them.**
|
|
44
|
+
|
|
45
|
+
*(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.)*
|
|
46
|
+
|
|
47
|
+
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 `put`s produce two separate `batch()` calls; `InMemoryStore` last-write-wins clobbers the first.)
|
|
48
|
+
|
|
49
|
+
`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.
|
|
50
|
+
|
|
51
|
+
## The swap
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
from agentsync import SyncedStore # 1
|
|
55
|
+
store = SyncedStore() # 2
|
|
56
|
+
graph = builder.compile(store=store) # 3
|
|
57
|
+
|
|
58
|
+
def my_node(state, store): # 4 langgraph injects the store
|
|
59
|
+
with store.acting_as("researcher"): # 5 attribute your writes
|
|
60
|
+
store.put(("ctx",), "notes", {"tags": [...]}) # 6
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Install
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
pip install extodan-agentsync # when published; meanwhile:
|
|
67
|
+
pip install -e ".[demo]" # from a clone (or `make setup` with uv)
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Requires Python 3.10+. Only runtime deps: `langgraph>=1.2,<2` and `loro`.
|
|
71
|
+
|
|
72
|
+
## Watch it work
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
make example # runs the /examples LangGraph swap demo
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
The same two-agent graph runs on `InMemoryStore` then `SyncedStore`:
|
|
79
|
+
|
|
80
|
+
```
|
|
81
|
+
BASELINE — InMemoryStore
|
|
82
|
+
final value: {'tags': ['benchmark'], 'status': 'published'}
|
|
83
|
+
-> the researcher's tags/findings/status are GONE. No error.
|
|
84
|
+
|
|
85
|
+
SWAP — SyncedStore
|
|
86
|
+
final value: {'tags': ['agents','benchmark','crdt'], 'status': '<escalated:status>'}
|
|
87
|
+
|
|
88
|
+
MERGEABLE WRITES (tags, findings):
|
|
89
|
+
synced tags : ['agents','benchmark','crdt'] <- both agents preserved
|
|
90
|
+
synced findings: concatenated <- both agents preserved
|
|
91
|
+
|
|
92
|
+
SEMANTIC CONFLICT (status: 'draft' vs 'published'):
|
|
93
|
+
baseline status: 'published' <- silently picked
|
|
94
|
+
synced status: ESCALATED (not auto-resolved)
|
|
95
|
+
contenders: researcher='draft', writer='published' <- flagged, attributed
|
|
96
|
+
|
|
97
|
+
ATTRIBUTION (per-write, survives the merge):
|
|
98
|
+
tags:researcher:0 -> agent=researcher tags:writer:0 -> agent=writer
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## API
|
|
102
|
+
|
|
103
|
+
| Method | What it does |
|
|
104
|
+
|---|---|
|
|
105
|
+
| `SyncedStore()` | Drop-in for `InMemoryStore`. Override: zero. |
|
|
106
|
+
| `store.acting_as(agent_id)` | Context manager — attribute the puts inside to an agent. |
|
|
107
|
+
| `store.on_escalation(cb)` | Register a callback fired when a semantic conflict is detected. |
|
|
108
|
+
| `store.escalations()` | List all unresolved conflicts (both contenders attributed). |
|
|
109
|
+
| `store.attribution()` | Per-key, per-write attribution map (which agent wrote each field). |
|
|
110
|
+
|
|
111
|
+
## Honest scope (read this before adopting)
|
|
112
|
+
|
|
113
|
+
This is the trust pitch, not a disclaimer.
|
|
114
|
+
|
|
115
|
+
**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.
|
|
116
|
+
|
|
117
|
+
**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.
|
|
118
|
+
|
|
119
|
+
**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.
|
|
120
|
+
|
|
121
|
+
**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.
|
|
122
|
+
|
|
123
|
+
## Known limitations
|
|
124
|
+
|
|
125
|
+
Stated plainly, because hiding them is worse than having them:
|
|
126
|
+
|
|
127
|
+
- **No persistence.** `SyncedStore` is 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.
|
|
128
|
+
- **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.
|
|
129
|
+
- **Single-process, in-memory.** No cross-process replication yet. Two LangGraph processes each holding a `SyncedStore` do not sync with each other.
|
|
130
|
+
- **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 mode `SyncedStore` prevents for same-type writes; it is a known gap across the type boundary, tracked in `tests/test_adversarial.py`, and intended to escalate in a future version. Same-type concurrent writes (the common case) are not affected.
|
|
131
|
+
- **Field "kind" is inferred from value shape, not declared.** Lists union; string values under keys named `*text`/`notes`/`findings`/`log` concatenate; everything else is treated as a scalar (and therefore as a conflict surface). There is no schema declaration API yet.
|
|
132
|
+
- **Attribution requires `acting_as`.** Writes made outside a `store.acting_as(...)` block are attributed to `"anonymous"` — attribution completeness then can't be guaranteed.
|
|
133
|
+
|
|
134
|
+
## The benchmark behind it
|
|
135
|
+
|
|
136
|
+
`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:
|
|
137
|
+
|
|
138
|
+
```
|
|
139
|
+
workload strategy verdict outcome writes_lost escals model_calls latency_ms
|
|
140
|
+
clean_merge lww FAIL corrupted 3 0 0 ~0.2
|
|
141
|
+
clean_merge transactional PASS auto_merged 0 0 0 ~0.2
|
|
142
|
+
clean_merge crdt PASS auto_merged 0 0 0 ~1.6
|
|
143
|
+
semantic_conflict lww FAIL corrupted 0 0 0 ~0.03
|
|
144
|
+
semantic_conflict transactional PASS resolved 0 1 1 ~308
|
|
145
|
+
semantic_conflict crdt PASS escalated 0 1 0 ~0.7
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
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](#benchmark-results).
|
|
149
|
+
|
|
150
|
+
## How merge decisions are made
|
|
151
|
+
|
|
152
|
+
| Write type | Merge behavior |
|
|
153
|
+
|---|---|
|
|
154
|
+
| Lists | Union (order-preserving, deduped) |
|
|
155
|
+
| Text (keys named `*text`/`notes`/`findings`/`log`) | Concatenation |
|
|
156
|
+
| Nested dicts | Deep-merge |
|
|
157
|
+
| Scalars set to the *same* value | Idempotent — no conflict |
|
|
158
|
+
| Scalars set to *different* values | **Escalation** (both contenders attributed) |
|
|
159
|
+
|
|
160
|
+
Merge layer is [Loro](https://loro.dev) (eg-walker family CRDT) — deterministic, coordinator-free convergence. Every write carries an `agent_id` that survives every merge.
|
|
161
|
+
|
|
162
|
+
## Quick start
|
|
163
|
+
|
|
164
|
+
```bash
|
|
165
|
+
make setup # uv sync --all-extras (provisions Python 3.12)
|
|
166
|
+
make example # LangGraph swap demo (the landing-page asset)
|
|
167
|
+
make bench # three-way benchmark harness, comparison table
|
|
168
|
+
make test # invariant + integration suite (15 tests)
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
## Benchmark results
|
|
172
|
+
|
|
173
|
+
Same workload through all three strategies behind one interface.
|
|
174
|
+
|
|
175
|
+
- **`outcome`** — *how* the strategy reached its end state. Three PASSes are not the same thing. Read it adversarially.
|
|
176
|
+
- **`writes_lost`** — the corruption signal. Nonzero on a mergeable workload = silent write drop.
|
|
177
|
+
|
|
178
|
+
**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.
|
|
179
|
+
|
|
180
|
+
**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](#honest-scope-read-this-before-adopting) above for why "800× faster to flag than to resolve" is the correct framing.
|
|
181
|
+
|
|
182
|
+
**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.
|
|
183
|
+
|
|
184
|
+
## Status
|
|
185
|
+
|
|
186
|
+
**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.
|
|
187
|
+
|
|
188
|
+
## Grounding references
|
|
189
|
+
|
|
190
|
+
- eg-walker — arXiv [2409.14252](https://arxiv.org/abs/2409.14252); `josephg/eg-walker-reference`; `josephg/diamond-types`.
|
|
191
|
+
- Loro — [loro.dev](https://loro.dev) (the merge layer this uses).
|
|
192
|
+
- Rival benchmarked — CoAgent MTPO, arXiv [2606.15376](https://arxiv.org/abs/2606.15376).
|
|
193
|
+
- Boundary case (not a target) — STORM, arXiv [2605.20563](https://arxiv.org/abs/2605.20563).
|
|
194
|
+
|
|
195
|
+
## License
|
|
196
|
+
|
|
197
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# agentsync
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/extodan-agentsync/)
|
|
4
|
+
[](https://github.com/Extodan-Corp/AgentSync/actions/workflows/ci.yml)
|
|
5
|
+
[](https://opensource.org/licenses/MIT)
|
|
6
|
+
[](https://www.python.org/downloads/)
|
|
7
|
+
|
|
8
|
+

|
|
9
|
+
|
|
10
|
+
**Drop-in LangGraph store that merges concurrent agent writes instead of silently dropping them.**
|
|
11
|
+
|
|
12
|
+
*(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.)*
|
|
13
|
+
|
|
14
|
+
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 `put`s produce two separate `batch()` calls; `InMemoryStore` last-write-wins clobbers the first.)
|
|
15
|
+
|
|
16
|
+
`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.
|
|
17
|
+
|
|
18
|
+
## The swap
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
from agentsync import SyncedStore # 1
|
|
22
|
+
store = SyncedStore() # 2
|
|
23
|
+
graph = builder.compile(store=store) # 3
|
|
24
|
+
|
|
25
|
+
def my_node(state, store): # 4 langgraph injects the store
|
|
26
|
+
with store.acting_as("researcher"): # 5 attribute your writes
|
|
27
|
+
store.put(("ctx",), "notes", {"tags": [...]}) # 6
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Install
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pip install extodan-agentsync # when published; meanwhile:
|
|
34
|
+
pip install -e ".[demo]" # from a clone (or `make setup` with uv)
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Requires Python 3.10+. Only runtime deps: `langgraph>=1.2,<2` and `loro`.
|
|
38
|
+
|
|
39
|
+
## Watch it work
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
make example # runs the /examples LangGraph swap demo
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
The same two-agent graph runs on `InMemoryStore` then `SyncedStore`:
|
|
46
|
+
|
|
47
|
+
```
|
|
48
|
+
BASELINE — InMemoryStore
|
|
49
|
+
final value: {'tags': ['benchmark'], 'status': 'published'}
|
|
50
|
+
-> the researcher's tags/findings/status are GONE. No error.
|
|
51
|
+
|
|
52
|
+
SWAP — SyncedStore
|
|
53
|
+
final value: {'tags': ['agents','benchmark','crdt'], 'status': '<escalated:status>'}
|
|
54
|
+
|
|
55
|
+
MERGEABLE WRITES (tags, findings):
|
|
56
|
+
synced tags : ['agents','benchmark','crdt'] <- both agents preserved
|
|
57
|
+
synced findings: concatenated <- both agents preserved
|
|
58
|
+
|
|
59
|
+
SEMANTIC CONFLICT (status: 'draft' vs 'published'):
|
|
60
|
+
baseline status: 'published' <- silently picked
|
|
61
|
+
synced status: ESCALATED (not auto-resolved)
|
|
62
|
+
contenders: researcher='draft', writer='published' <- flagged, attributed
|
|
63
|
+
|
|
64
|
+
ATTRIBUTION (per-write, survives the merge):
|
|
65
|
+
tags:researcher:0 -> agent=researcher tags:writer:0 -> agent=writer
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## API
|
|
69
|
+
|
|
70
|
+
| Method | What it does |
|
|
71
|
+
|---|---|
|
|
72
|
+
| `SyncedStore()` | Drop-in for `InMemoryStore`. Override: zero. |
|
|
73
|
+
| `store.acting_as(agent_id)` | Context manager — attribute the puts inside to an agent. |
|
|
74
|
+
| `store.on_escalation(cb)` | Register a callback fired when a semantic conflict is detected. |
|
|
75
|
+
| `store.escalations()` | List all unresolved conflicts (both contenders attributed). |
|
|
76
|
+
| `store.attribution()` | Per-key, per-write attribution map (which agent wrote each field). |
|
|
77
|
+
|
|
78
|
+
## Honest scope (read this before adopting)
|
|
79
|
+
|
|
80
|
+
This is the trust pitch, not a disclaimer.
|
|
81
|
+
|
|
82
|
+
**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.
|
|
83
|
+
|
|
84
|
+
**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.
|
|
85
|
+
|
|
86
|
+
**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.
|
|
87
|
+
|
|
88
|
+
**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.
|
|
89
|
+
|
|
90
|
+
## Known limitations
|
|
91
|
+
|
|
92
|
+
Stated plainly, because hiding them is worse than having them:
|
|
93
|
+
|
|
94
|
+
- **No persistence.** `SyncedStore` is 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.
|
|
95
|
+
- **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.
|
|
96
|
+
- **Single-process, in-memory.** No cross-process replication yet. Two LangGraph processes each holding a `SyncedStore` do not sync with each other.
|
|
97
|
+
- **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 mode `SyncedStore` prevents for same-type writes; it is a known gap across the type boundary, tracked in `tests/test_adversarial.py`, and intended to escalate in a future version. Same-type concurrent writes (the common case) are not affected.
|
|
98
|
+
- **Field "kind" is inferred from value shape, not declared.** Lists union; string values under keys named `*text`/`notes`/`findings`/`log` concatenate; everything else is treated as a scalar (and therefore as a conflict surface). There is no schema declaration API yet.
|
|
99
|
+
- **Attribution requires `acting_as`.** Writes made outside a `store.acting_as(...)` block are attributed to `"anonymous"` — attribution completeness then can't be guaranteed.
|
|
100
|
+
|
|
101
|
+
## The benchmark behind it
|
|
102
|
+
|
|
103
|
+
`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:
|
|
104
|
+
|
|
105
|
+
```
|
|
106
|
+
workload strategy verdict outcome writes_lost escals model_calls latency_ms
|
|
107
|
+
clean_merge lww FAIL corrupted 3 0 0 ~0.2
|
|
108
|
+
clean_merge transactional PASS auto_merged 0 0 0 ~0.2
|
|
109
|
+
clean_merge crdt PASS auto_merged 0 0 0 ~1.6
|
|
110
|
+
semantic_conflict lww FAIL corrupted 0 0 0 ~0.03
|
|
111
|
+
semantic_conflict transactional PASS resolved 0 1 1 ~308
|
|
112
|
+
semantic_conflict crdt PASS escalated 0 1 0 ~0.7
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
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](#benchmark-results).
|
|
116
|
+
|
|
117
|
+
## How merge decisions are made
|
|
118
|
+
|
|
119
|
+
| Write type | Merge behavior |
|
|
120
|
+
|---|---|
|
|
121
|
+
| Lists | Union (order-preserving, deduped) |
|
|
122
|
+
| Text (keys named `*text`/`notes`/`findings`/`log`) | Concatenation |
|
|
123
|
+
| Nested dicts | Deep-merge |
|
|
124
|
+
| Scalars set to the *same* value | Idempotent — no conflict |
|
|
125
|
+
| Scalars set to *different* values | **Escalation** (both contenders attributed) |
|
|
126
|
+
|
|
127
|
+
Merge layer is [Loro](https://loro.dev) (eg-walker family CRDT) — deterministic, coordinator-free convergence. Every write carries an `agent_id` that survives every merge.
|
|
128
|
+
|
|
129
|
+
## Quick start
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
make setup # uv sync --all-extras (provisions Python 3.12)
|
|
133
|
+
make example # LangGraph swap demo (the landing-page asset)
|
|
134
|
+
make bench # three-way benchmark harness, comparison table
|
|
135
|
+
make test # invariant + integration suite (15 tests)
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## Benchmark results
|
|
139
|
+
|
|
140
|
+
Same workload through all three strategies behind one interface.
|
|
141
|
+
|
|
142
|
+
- **`outcome`** — *how* the strategy reached its end state. Three PASSes are not the same thing. Read it adversarially.
|
|
143
|
+
- **`writes_lost`** — the corruption signal. Nonzero on a mergeable workload = silent write drop.
|
|
144
|
+
|
|
145
|
+
**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.
|
|
146
|
+
|
|
147
|
+
**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](#honest-scope-read-this-before-adopting) above for why "800× faster to flag than to resolve" is the correct framing.
|
|
148
|
+
|
|
149
|
+
**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.
|
|
150
|
+
|
|
151
|
+
## Status
|
|
152
|
+
|
|
153
|
+
**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.
|
|
154
|
+
|
|
155
|
+
## Grounding references
|
|
156
|
+
|
|
157
|
+
- eg-walker — arXiv [2409.14252](https://arxiv.org/abs/2409.14252); `josephg/eg-walker-reference`; `josephg/diamond-types`.
|
|
158
|
+
- Loro — [loro.dev](https://loro.dev) (the merge layer this uses).
|
|
159
|
+
- Rival benchmarked — CoAgent MTPO, arXiv [2606.15376](https://arxiv.org/abs/2606.15376).
|
|
160
|
+
- Boundary case (not a target) — STORM, arXiv [2605.20563](https://arxiv.org/abs/2605.20563).
|
|
161
|
+
|
|
162
|
+
## License
|
|
163
|
+
|
|
164
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# assets/
|
|
2
|
+
|
|
3
|
+
Recording for the README and launch post.
|
|
4
|
+
|
|
5
|
+
## `agentsync.gif`
|
|
6
|
+
|
|
7
|
+
A ~7s terminal recording of `make repro` (`python -m agentsync.repro`) showing:
|
|
8
|
+
1. the stock `InMemoryStore` silently dropping one of two parallel writes (no error), then
|
|
9
|
+
2. `SyncedStore` keeping both writes AND escalating the `status` semantic conflict with attribution.
|
|
10
|
+
|
|
11
|
+
Embedded at the top of `README.md`.
|
|
12
|
+
|
|
13
|
+
## Re-recording / producing an asciinema cast
|
|
14
|
+
|
|
15
|
+
The GIF was rendered deterministically with [`vhs`](https://github.com/charmbracelet/vhs)
|
|
16
|
+
from `agentsync.tape`. To regenerate (requires `vhs` + `ttyd`, both via Homebrew):
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
brew install vhs ttyd
|
|
20
|
+
vhs assets/agentsync.tape # -> assets/agentsync.gif
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
For an asciinema cast instead of a GIF:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
brew install asciinema
|
|
27
|
+
asciinema rec --command="uv run python -m agentsync.repro" assets/agentsync.cast
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Storyboard (what each frame shows, for a manual one-take recording)
|
|
31
|
+
|
|
32
|
+
| Beat | Time | On screen |
|
|
33
|
+
|---|---|---|
|
|
34
|
+
| 1 | 0–2s | `$ make repro` typed and run |
|
|
35
|
+
| 2 | 2–4s | `detected langgraph version: 1.2.6` header |
|
|
36
|
+
| 3 | 4–7s | **PART 1** — `final store value: {'tags': ['benchmark'], ...}` + `agent_A's tags survived? NO — silently dropped` + `BUG REPRODUCED: ... No exception was raised.` |
|
|
37
|
+
| 4 | 7–10s | **PART 2** — `final store value: {'status': '<escalated:status>', 'tags': ['agents','benchmark','crdt']}` + `semantic conflict on 'status' ESCALATED: A='draft', B='published'` |
|
|
38
|
+
| 5 | 10–12s | **VERDICT** — `stock InMemoryStore: silent write-loss reproduced` / `SyncedStore: merged tags = [...], 1 escalation(s)` |
|
|
39
|
+
|
|
40
|
+
The single command `make repro` produces beats 1–5 end-to-end in ~0.3s of compute; the recording just types the command and dwells.
|
|
Binary file
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
Output assets/agentsync.gif
|
|
2
|
+
|
|
3
|
+
# Visual settings tuned for a README-embed-width recording.
|
|
4
|
+
Set FontSize 14
|
|
5
|
+
Set Width 1200
|
|
6
|
+
Set Height 620
|
|
7
|
+
Set Padding 16
|
|
8
|
+
Set Theme "Dracula"
|
|
9
|
+
|
|
10
|
+
# Cursor blink off so the still frames read clean.
|
|
11
|
+
Set CursorBlink false
|
|
12
|
+
|
|
13
|
+
# Brisk but visible typing.
|
|
14
|
+
Set TypingSpeed 0.03
|
|
15
|
+
Set Framerate 24
|
|
16
|
+
|
|
17
|
+
Hide
|
|
18
|
+
|
|
19
|
+
Type "make repro"
|
|
20
|
+
Enter
|
|
21
|
+
|
|
22
|
+
Show
|
|
23
|
+
|
|
24
|
+
# Let the program print its full output (repro completes in ~0.3s).
|
|
25
|
+
Sleep 4s
|
|
26
|
+
|
|
27
|
+
# Hold on the verdict so the last frame is the punchline.
|
|
28
|
+
Sleep 3s
|
|
File without changes
|