jatin.blog ~ $
$ cat ai-engineering/multi-agent-shared-memory.md

Multi-Agent Shared Memory

Shared memory for multiple agents with explicit scope, consistency, conflict handling, and audit.

Jatin Bansal@blog:~/ai-engineering$ open multi-agent-shared-memory

A research crew runs three agents in parallel: a web-search specialist, an internal-docs specialist, and a synthesis agent. Each maintains its own working-memory scratchpad and its own episodic store. The first two finish in 90 seconds; the synthesizer takes their outputs and produces a clean report in another 30. Total wall-clock: two minutes. Two days later the same three agents run an overnight job: the web specialist crawls 200 pages, the docs specialist reads 80 internal RFCs, the synthesizer is supposed to assemble a state-of-the-quarter brief. This time it takes eleven hours, the synthesizer’s report references “the customer churn data the docs agent surfaced”: except the docs agent never surfaced that, the synthesizer made it up, and somewhere in the middle of the run the web agent silently overwrote the docs agent’s “open questions” list with its own. The bug is not in any single agent. The bug is in what they share.

Pattern 1: The supervisor-mediated channel

Single-writer-multiple-reader. The supervisor owns the canonical state: a plan, an open-questions list, a synthesized brief: and updates it as worker responses come in. Workers read it as part of their input prompt but never write to it directly; their findings flow back through the handoff payload, and the supervisor decides what to commit.

This is the safest pattern. There are no concurrent writes, so there are no consistency questions. Every observation about distributed databases that follows simplifies away: the supervisor is the database, the workers are stateless clients, and the worst that can happen is a stale read in a worker (the supervisor updated the plan after the worker received its input) which the supervisor can detect on next handoff and re-dispatch.

The cost: the supervisor is a bottleneck and a context inflater. Every write goes through its loop; every write pays a model call to decide how to commit; the supervisor’s own conversation history grows as it sees every worker’s findings. The 15× token tax from the multi-agent orchestration article is partly an artifact of this: the supervisor pays for the union of all sub-tasks because it’s the only writer.

When this is right: small specialist counts (≤5), tasks where the supervisor’s per-step decision quality matters more than parallel write throughput, anywhere the audit story has to be clean. When this is wrong: highly parallel breadth-first tasks where a hundred subagents are emitting findings in parallel and routing every one through a single supervisor’s context window is a fork-bomb on the supervisor’s prompt rather than on its child processes.

Pattern 2: The shared block

The supervisor’s monopoly is broken. Two or more agents each have read and write access to the same typed slot. Concurrency control becomes mandatory.

This is the pattern Letta’s shared memory blocks productionize. A block is created once (client.blocks.create(...)) and attached to multiple agents via block_ids; each attached agent can read or write to it through three different operations with different safety properties. The published memory-operations table is worth quoting verbatim because the asymmetry it documents is exactly the design lesson:

  • memory_insert: append-only, multi-writer safe. Multiple agents can call it simultaneously without conflicts; the block grows monotonically. This is the operation you reach for when you don’t know whether you’ll have multi-agent contention.
  • memory_replace: match-then-replace, mostly safe. Fails if the target string has changed since it was last read. This is optimistic concurrency control: the same primitive Postgres exposes as compare-and-swap and Git exposes as a non-fast-forward push reject.
  • memory_rethink: last-writer-wins, unsafe under contention. The full block is rewritten with no coordination. Letta’s guidance: “Designate one agent (or sleeptime) as the ‘owner’ for heavy edits. Other agents append via memory_insert.”

The pattern that falls out is a tier-based access policy. Most agents are allowed memory_insert only; one designated owner (often a sleep-time consolidation agent) gets memory_replace and memory_rethink and runs when no other agent is actively writing. The shape is a classic single-writer compaction over a multi-writer log: exactly the Kafka log-compaction model from the reflection article, specialized to a shared working-memory block.

CrewAI’s unified memory model makes a different design choice. Memory is shared by default across all agents in a crew, with hierarchical scopes (/project/alpha, /agent/researcher) carving private views out of the shared whole. Where Letta gives you tight control over which agents share which block, CrewAI gives you a global namespace with read-time scope filters. Both choices are defensible; the practical difference is that CrewAI’s default is “everyone sees everything” with opt-in privacy, and Letta’s default is “nothing is shared” with opt-in sharing: and the consistency problems show up at different stages of the design.

Pattern 3: The cross-thread store

The store-level shared memory. LangGraph’s Store API: backed by Postgres, Redis, MongoDB, or an in-memory implementation for development: exposes a namespaced key-value layer that any thread (any agent, any session) can put, get, or search against. The Store sits below the per-thread checkpointer; checkpointer state is private to one thread, Store state is shared across all of them.

The semantics are deliberately bare: put((namespace,), key, value) writes a row; get((namespace,), key) reads it; search((namespace,), query=...) does semantic retrieval if the namespace was configured with an embedding model. The concurrency model is whatever the backend implements: Postgres-backed stores get serializable transactions if you ask for them, Redis-backed stores get last-writer-wins by default, the in-memory dev store is racy. The store does not enforce consistency; it inherits the consistency of its backend.

The mental model that pays off is the Store is a shared blackboard with a key-value interface and no scheduler. Every agent can read and write; the namespace is the only scoping mechanism; nothing wakes an agent when a key it cares about changes. This is the cost of the API surface being small: coordination is up to the agents and the harness above them. Production deployments add the missing pieces: a notification mechanism on top of the Store (Redis pub/sub, Postgres LISTEN/NOTIFY), a versioning column for optimistic concurrency, an audit-log table that captures every write for compliance and debugging.

Pattern 4: The blackboard

The full distributed-shared-memory model. A typed store with multiple writers, multiple readers, and a scheduler that activates agents based on the store’s current state. The Hearsay-II revival in 2025 (the Lu & Sasaki paper and the follow-up information-discovery paper, reporting 13–57% relative gains over baselines on data-science discovery) is the modern reference, and the architecture is the right one to reach for when:

  • The agent count is genuinely large (10+ specialists).
  • Which agent runs next is data-dependent and not predictable in advance.
  • Agents need to react to other agents’ writes, not just to the user or the supervisor.

The shape: a typed board with regions (hypotheses, evidence, open_questions, conclusions), a write log (every write is timestamped, attributed, and versioned), a scheduler that scans the board and dispatches the next agent whose preconditions match the current state, and explicit conflict-resolution rules on each region (hypotheses is append-only; conclusions is single-writer; open_questions is multi-writer with CRDT-style set-union semantics).

The blackboard is what you build when no single supervisor’s prompt is wide enough to route the whole task. The supervisor pattern’s bottleneck: the lead agent’s context window growing linearly with task complexity: is solved by making the board itself the coordination surface; agents look at the board to decide what to do, the supervisor (if there is one) just observes. The cost is that everything subtle about distributed shared memory now has to be designed, not avoided.

Consistency decisions

Any shared-memory design must specify visibility, conflict resolution, audit, and deletion semantics. These decisions turn “shared memory” from a vibes-based design into something an on-call engineer can debug at 3am.

  1. When does a write become visible? Read-your-writes (an agent’s own writes are immediately visible to itself) is table stakes. Cross-agent visibility splits into strong consistency (every other agent sees the write on its next read) and eventual consistency (other agents may see a stale view for some bounded period). Letta’s docs are explicit: shared blocks are strong-consistency within a single Letta server because all blocks live in the same Postgres row and the database serializes the reads. LangGraph’s Store-with-Redis is eventual by default; Store-with-Postgres is strong if you ask for it. The right choice is workload-dependent: a fan-out research crew where agents work on independent regions of a problem can tolerate eventual visibility; a multi-agent code-edit crew where agents are touching the same file cannot.

  2. How are concurrent conflicting writes resolved? Common conflict policies include last-writer-wins, optimistic concurrency, and CRDT-style merge. Last-writer-wins: the simplest, and the one most home-grown shared-memory systems start with: is unsafe whenever the lost write contained information that wasn’t in the winner. Optimistic concurrency (compare-and-swap, the model Letta’s memory_replace and Git’s non-fast-forward push use) catches conflicts at write time and forces the loser to retry against the updated view; the cost is the retry, the benefit is no silent data loss. CRDT-style merge (the CodeCRDT approach) defines a deterministic merge operator on the data type so that any two concurrent writes can be combined without conflict; the cost is the data type has to be designed for CRDT semantics (sets, counters, Y.js-style sequence types: not free-form prose), the benefit is the system never blocks on a conflict.

  3. What is the audit trail? Every shared-memory write needs who, when, what, and (sometimes) why. The “who” makes attribution possible; the “when” enables temporal queries; the “what” is the payload; the “why” is the optional reasoning the agent emitted with the write. Skipping this turns every shared-memory bug into a forensics nightmare: the report mentions a fact that wasn’t in any agent’s individual output, and there’s no way to find which write introduced it. The cheapest implementation is an append-only audit log on the side of the store; the most expensive is a full event-sourced reconstruction (the store is the event log, with materialized views for the current state). The middle ground: a store plus an audit table: is where most production systems land.

  4. What is the deletion path? When a write is wrong, who can take it back, and what cascading invalidation does that require? The memory-conflict article covered the supersession-vs-deletion distinction for single-agent stores; in multi-agent, the answer is harder because other agents may already have read the wrong write and acted on it. The mature pattern is to surface the deletion as an event on the store rather than a silent state mutation: every reader can observe “the previous value at key K was wrong, here’s the corrected one” and choose whether to re-run its own logic against the new view. The Kafka-tombstone parallel is direct: deletion is just another write with special semantics, recorded in the same log as everything else.

Scope and namespace hierarchy

Whatever pattern you pick, the namespacing of the shared memory is what makes it tractable at scale. The naïve “one global store everyone reads and writes” is the worst case: every write contends with every other write, and the persona-leak production incident from the identity article generalizes from cross-persona to cross-tenant to cross-agent leakage.

Shared memory commonly uses these scopes:

  • Per-task scope. Each multi-agent task gets its own namespace; nothing leaks between tasks. The default for blackboard architectures. The cost is no cross-task learning: useful patterns from one task don’t show up in the next unless explicitly promoted to a higher-scoped store.
  • Per-tenant scope. All tasks for one organization share a namespace; different organizations are strictly isolated. The default for SaaS deployments. The cost is some agents that should learn across the tenant (the support agent learning from the engineering agent’s resolutions) need explicit cross-agent visibility within the tenant.
  • Per-agent-role scope. All researchers across all tasks share a “researcher” namespace; all synthesizers share a “synthesizer” namespace. Useful for procedural memory: every researcher learns successful research strategies that future researchers can retrieve. The cost is that the role-level memory can carry biases from one task into another (a researcher who became confident about Acme Corp on Monday surfaces those beliefs as facts on a Wednesday task about a different company).

Most production systems run all three axes simultaneously, with explicit promotion rules between them. The LangMem-style namespace hierarchy (("tenant", tenant_id, "task", task_id) for hot scratchpads, ("tenant", tenant_id, "role", role) for role memories, ("system", "shared") for cross-tenant prior art that’s been reviewed) is the architectural pattern; the exact backing store is incidental.

Versioned shared scratchpad in Python

A minimal but production-shaped shared scratchpad. Two agents read and write to the same Postgres-backed block; writes are versioned, conflicts are caught at write time, and the audit log is the source of truth. Uses the Anthropic SDK and psycopg. Install: pip install anthropic psycopg[binary].

python
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
"""
shared_scratchpad.py

A multi-agent shared scratchpad with:
  - Append-only and CAS-style replace operations
  - Per-task namespacing
  - Audit log of every write
  - Optimistic concurrency: replace fails if the version moved
"""

from __future__ import annotations
import time
import uuid
import psycopg
import anthropic

SCHEMA = """
CREATE TABLE IF NOT EXISTS scratchpad (
    namespace TEXT NOT NULL,
    key TEXT NOT NULL,
    value TEXT NOT NULL,
    version INTEGER NOT NULL,
    updated_by TEXT NOT NULL,
    updated_at DOUBLE PRECISION NOT NULL,
    PRIMARY KEY (namespace, key)
);
CREATE TABLE IF NOT EXISTS scratchpad_audit (
    audit_id UUID PRIMARY KEY,
    namespace TEXT NOT NULL,
    key TEXT NOT NULL,
    op TEXT NOT NULL,             -- 'insert' | 'replace' | 'tombstone'
    old_value TEXT,
    new_value TEXT,
    actor TEXT NOT NULL,
    reason TEXT,                  -- the agent's why-for-this-write
    ts DOUBLE PRECISION NOT NULL,
    version INTEGER NOT NULL
);
"""


class SharedScratchpad:
    """Multi-writer scratchpad backed by Postgres with versioned writes."""

    def __init__(self, dsn: str):
        self.dsn = dsn
        with psycopg.connect(dsn, autocommit=True) as conn:
            conn.execute(SCHEMA)

    # -- append-only: multi-writer safe by construction ----------------------
    def insert(self, ns: str, key: str, value: str, actor: str, reason: str = "") -> int:
        """Append to a multi-writer region. Concatenates with newline."""
        with psycopg.connect(self.dsn, autocommit=True) as conn:
            with conn.transaction():
                row = conn.execute(
                    "SELECT value, version FROM scratchpad WHERE namespace=%s AND key=%s FOR UPDATE",
                    (ns, key),
                ).fetchone()
                if row is None:
                    new_value, new_version = value, 1
                    conn.execute(
                        "INSERT INTO scratchpad VALUES (%s, %s, %s, %s, %s, %s)",
                        (ns, key, new_value, new_version, actor, time.time()),
                    )
                else:
                    old_value, old_version = row
                    new_value = f"{old_value}\n{value}"
                    new_version = old_version + 1
                    conn.execute(
                        "UPDATE scratchpad SET value=%s, version=%s, updated_by=%s, updated_at=%s "
                        "WHERE namespace=%s AND key=%s",
                        (new_value, new_version, actor, time.time(), ns, key),
                    )
                self._audit(conn, ns, key, "insert", row[0] if row else None, new_value,
                            actor, reason, new_version)
                return new_version

    # -- replace: CAS-style; fails on version mismatch -----------------------
    def replace(self, ns: str, key: str, expected_version: int, value: str,
                actor: str, reason: str = "") -> int:
        """Replace iff version matches. Raises on conflict."""
        with psycopg.connect(self.dsn, autocommit=True) as conn:
            with conn.transaction():
                row = conn.execute(
                    "SELECT value, version FROM scratchpad WHERE namespace=%s AND key=%s FOR UPDATE",
                    (ns, key),
                ).fetchone()
                if row is None:
                    raise KeyError(f"{ns}/{key} not found")
                old_value, current_version = row
                if current_version != expected_version:
                    raise ConflictError(
                        f"version moved: expected {expected_version}, got {current_version}"
                    )
                new_version = current_version + 1
                conn.execute(
                    "UPDATE scratchpad SET value=%s, version=%s, updated_by=%s, updated_at=%s "
                    "WHERE namespace=%s AND key=%s",
                    (value, new_version, actor, time.time(), ns, key),
                )
                self._audit(conn, ns, key, "replace", old_value, value,
                            actor, reason, new_version)
                return new_version

    # -- read with version --------------------------------------------------
    def read(self, ns: str, key: str) -> tuple[str | None, int]:
        with psycopg.connect(self.dsn) as conn:
            row = conn.execute(
                "SELECT value, version FROM scratchpad WHERE namespace=%s AND key=%s",
                (ns, key),
            ).fetchone()
            return (row[0], row[1]) if row else (None, 0)

    def _audit(self, conn, ns, key, op, old, new, actor, reason, version):
        conn.execute(
            "INSERT INTO scratchpad_audit VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)",
            (uuid.uuid4(), ns, key, op, old, new, actor, reason, time.time(), version),
        )


class ConflictError(Exception):
    """Raised when a CAS replace finds the version moved underneath."""


# -----------------------------------------------------------------------------
# Demo: two agents collaborating on a shared scratchpad
# -----------------------------------------------------------------------------


def agent_turn(client, scratchpad: SharedScratchpad, ns: str, agent_name: str,
               instructions: str, user_msg: str):
    """One agent turn: read the shared block, reason, write back."""
    findings_value, _ = scratchpad.read(ns, "findings")
    open_q_value, open_q_version = scratchpad.read(ns, "open_questions")

    system = (
        f"{instructions}\n\n"
        f"You are {agent_name}. The shared scratchpad currently shows:\n"
        f"<findings>\n{findings_value or '(empty)'}\n</findings>\n"
        f"<open_questions version={open_q_version}>\n{open_q_value or '(empty)'}\n</open_questions>\n"
        "Emit your contribution as a short paragraph; do not duplicate existing findings."
    )
    resp = client.messages.create(
        model="claude-haiku-4-5",
        max_tokens=400,
        system=system,
        messages=[{"role": "user", "content": user_msg}],
    )
    contribution = resp.content[0].text.strip()

    # Append-only write is safe under any concurrency.
    new_version = scratchpad.insert(
        ns, "findings", f"[{agent_name}] {contribution}", agent_name,
        reason="incremental finding"
    )
    return contribution, new_version


def demo():
    client = anthropic.Anthropic()
    scratch = SharedScratchpad("postgresql://localhost/scratchpad_demo")
    ns = f"task-{uuid.uuid4().hex[:8]}"

    # Seed an open question via CAS.
    try:
        scratch.replace(ns, "open_questions", expected_version=0,
                        value="What are the top 3 production risks for the Q3 launch?",
                        actor="supervisor", reason="initial question")
    except KeyError:
        scratch.insert(ns, "open_questions",
                       "What are the top 3 production risks for the Q3 launch?",
                       actor="supervisor", reason="initial question")

    # Two specialists run in sequence, each appending findings.
    for agent, instr in [
        ("web_researcher", "You are a web research specialist."),
        ("db_researcher", "You are an internal-data specialist."),
    ]:
        contribution, v = agent_turn(
            client, scratch, ns, agent, instr,
            "Contribute one risk you can support with evidence."
        )
        print(f"{agent} wrote v{v}: {contribution[:80]}...")

    # Demonstrate CAS conflict: two agents try to replace the same key concurrently.
    _, v = scratch.read(ns, "open_questions")
    scratch.replace(ns, "open_questions", expected_version=v,
                    value="Updated by agent A", actor="agent_A", reason="first revision")
    try:
        scratch.replace(ns, "open_questions", expected_version=v,
                        value="Updated by agent B", actor="agent_B", reason="second revision")
    except ConflictError as e:
        print(f"Expected conflict caught: {e}")


if __name__ == "__main__":
    demo()

The implementation has several relevant properties. insert is append-only and safe under any concurrency: the FOR UPDATE row-lock serializes the read-modify-write, but the operation itself never rejects a write. replace is CAS-style: it requires the caller to pass the expected version and rejects with ConflictError if anyone else has written in between. The caller’s recovery is to re-read, re-reason, and re-attempt; the conflict is visible rather than silent. every write writes the audit log in the same transaction: there is no shared-memory mutation that isn’t reflected in scratchpad_audit. When the synthesizer later produces a report that contains a fact nobody remembers writing, the audit log is the place to look.