Skip to content

Latest commit

 

History

History
337 lines (235 loc) · 26.1 KB

File metadata and controls

337 lines (235 loc) · 26.1 KB

ADR-003: Memory as a Kernel Primitive

Status: Accepted — 2026-04-14 (Phases 1, 1.1, 2a, 2b shipped to commonly-dev; Phase 3 §Deliverable 3 shipped 2026-04-15) Author: Lily Shen Supersedes: (none — amends the ad-hoc implementation in backend/models/AgentMemory.ts and backend/routes/agentsRuntime.ts) Companion: docs/COMMONLY_SCOPE.md, ADR-001, ADR-004, ADR-005, ADR-006

Revision history

  • 2026-04-14 (initial draft): envelope schema, POST /memory/sync, 5-phase migration.
  • 2026-04-14 (post-Phase-2b amendment): corrections from what actually shipped, and driver-coupling corrections.
    • Shipped to commonly-dev: Phase 1 (v2 schema + backfill, PR #188), Phase 1.1 (server-stamp byteSize + updatedAt, PR #189), Phase 2a (POST /memory/sync with mode + dedup, PR #191), Phase 2b (OpenClaw commonly_read_my_memory + commonly_save_my_memory tool shims as the first driver consumer, PR #192).
    • New invariants named (8–11 below): cross-writer dedup invalidation; server-stamped byteSize / updatedAt / schemaVersion; canonical-stringify dedup keys; array-section merge is mode-dependent.
    • Phase 3 reframed driver-agnostic (was "OpenClaw driver promotion"). Driver-side promotion is delegated to the per-driver ADRs: ADR-005 (local CLI wrapper) and ADR-006 (webhook SDK). OpenClaw's HEARTBEAT-template update, if done, is one OpenClaw-internal task among many, not a gate on other drivers.
    • Kernel-coupling to OpenClaw deliberately removed — drivers land via ADR-005 and ADR-006 alongside the existing OpenClaw driver, not ahead of it.
  • 2026-04-15 (Phase 3 §Deliverable 3 shipped, PR #199 → commit 720fc28e11): end-to-end proof that memory is kernel-shaped lives at backend/__tests__/service/two-driver-memory-cross-check.test.js. Two agents in one pod — one simulating the ADR-005 CLI-wrapper (sourceRuntime: 'local-cli') and one simulating the ADR-006 Python SDK (sourceRuntime: 'webhook-sdk-py') — each write and read their own envelope via POST /memory/sync. Seven tests: isolation, server stamps, patch, full, dedup, v1 mirror, cross-token scoping — all behave identically regardless of driver.
  • 2026-08-26 (TASK-076): per-seat memory activity is derived from the newest timestamp among AGENT_WRITABLE_SECTIONS, never the envelope's updatedAt (which system exchanges can advance). New agent-authored section writes receive a server-stamped updatedAt, so the metric can name the actual saved section without trusting client metadata. Legacy section records without that stamp are omitted rather than fabricated during hydration.

Context

Commonly has a commonly_read_agent_memory / commonly_save_agent_memory tool pair backed by a single MongoDB collection:

// backend/models/AgentMemory.ts
{
  agentName: string,     // e.g. "openclaw"
  instanceId: string,    // e.g. "chief-of-staff"
  content: string,       // opaque blob
  createdAt, updatedAt,
}
// unique index: (agentName, instanceId)

Two endpoints: GET /api/agents/runtime/memory and PUT /api/agents/runtime/memory, both under agentRuntimeAuth. The tool was framed in its own description as "this agent's personal MEMORY.md, stored in the backend and persistent across sessions."

Audit: what's actually in it (2026-04-14)

23 records, ~45KB total across all agents. Breakdown of content by purpose:

Category Share Example
Dedup bookkeeping ~70% ## Commented {threadId:count}, ## Replied [msgIds], ## RepliedMsgs, ## PodVisits, ## StaleRevivalAt
Operational state caches ~20% ## Pods (name→id map — derivable from API), ## Posted [urls] (x-curator link dedup), ## ScannedRepos, ## ReviewedPRs, ## DevPodId/ChildPods, ## Runtime
Boilerplate ~10% # MEMORY.md header, ## Silent Replies NO_REPLY rules, ## Heartbeats instructions
Knowledge, opinions, accumulated context 0% —

Every agent is using the backend as a notepad for "don't double-reply across session clears." The richest record (x-curator, 15KB) is 82 dated URLs — still just a dedup cache for posted links.

The shadow layer we aren't using

Separately, OpenClaw ships a native per-agent file-memory layer that every agent already has provisioned but almost none populate:

/workspace/<agent>/MEMORY.md                 ← curated long-term memory (OpenClaw AGENTS.md convention)
/workspace/<agent>/memory/YYYY-MM-DD.md      ← daily notes
/workspace/<agent>/AGENTS.md                 ← startup instructions (read SOUL.md, USER.md, today's note, MEMORY.md)
/state/memory/<agent>.sqlite                 ← OpenClaw FTS + embedding index

All 24 agents have these files provisioned. None are populated beyond the four-line MEMORY.md template and a date-header in today's daily note. Meanwhile HEARTBEAT.md tells agents to only touch commonly_read/save_agent_memory, so the native layer is ignored in shared-pod sessions.

Why this matters now

Four reasons the current design doesn't hold:

  1. Multi-runtime is about to land. Today's implicit assumption — "memory = whatever the OpenClaw MEMORY.md convention is" — breaks as soon as we add the webhook driver, a Python/LangGraph adapter, the Vercel cloud-agent adapter, or any BYO HTTP agent. Each will have its own local persistence (or none). The Commonly layer needs to be a standardized envelope every runtime can promote into, not a driver-shaped blob.

  2. PVC loss insurance is real. During the GKE migration the gateway PVC was recreated cleanly. /workspace and /state/memory were reset. If we had rich file memory at the time, it would have been lost. A canonical backup in the kernel store is the insurance.

  3. Social norms break without access rules. Agents today could in principle read each other's memory blobs via the same tool if we let them — which would encourage silent "mind-reading" over actual conversation. That inverts the product thesis: agents and humans coexist and talk to each other. The kernel has to enforce the norm.

  4. Commonly is shaped like an OS, not an app. The project charter (CLAUDE.md) frames Commonly as a kernel with pluggable drivers. Identity, events, tools are already kernel-shaped. Memory currently isn't — it's a tool. That's the gap this ADR closes.


Decision

Treat memory as a first-class CAP (Commonly Agent Protocol) kernel primitive, alongside identity, events, and tools. The kernel owns the schema, visibility rules, and promotion contract. Runtime drivers own local persistence and map their native shape to the kernel schema on promote.

The kernel schema (AgentMemory v2)

Move from a single content: string blob to a typed envelope:

interface AgentMemoryEnvelope {
  agentName: string;          // e.g. "openclaw", "webhook", "native"
  instanceId: string;         // e.g. "chief-of-staff"
  sourceRuntime: string;      // concrete driver that wrote this (e.g. "openclaw", "webhook")
  schemaVersion: 2;

  sections: {
    soul?:          MemorySection;       // identity — who I am
    long_term?:     MemorySection;       // MEMORY.md equivalent — curated
    daily?:         DailySection[];      // recent daily notes (bounded window)
    dedup_state?:   MemorySection;       // RepliedMsgs / Commented / PodVisits etc.
    relationships?: RelationshipNote[];  // per-peer notes
    shared?:        MemorySection;       // opt-in cross-agent readable
    runtime_meta?:  MemorySection;       // host / model / capabilities snapshot
  };

  // Backwards-compat during migration
  content?: string;           // v1 blob — read-only after migration completes

  updatedAt: Date;
  createdAt: Date;
}

interface MemorySection {
  content: string;            // markdown; opaque to Commonly
  visibility: 'private' | 'pod' | 'public';   // default 'private'
  updatedAt?: Date;           // server-stamped; absent on pre-TASK-076 records
  byteSize: number;           // for quota
}

interface DailySection {
  date: string;               // 'YYYY-MM-DD'
  content: string;
  visibility: 'private' | 'pod' | 'public';
  updatedAt?: Date;           // server-stamped write time; absent on pre-TASK-076 entries
}

interface RelationshipNote {
  otherInstanceId: string;    // peer agent or user id
  notes: string;              // what I know / remember about them
  visibility: 'private' | 'pod' | 'public';
  updatedAt?: Date;           // server-stamped; absent on pre-TASK-076 records
}

Unique index stays (agentName, instanceId) — one envelope per agent instance. No change at the identity layer.

Visibility & access rules

  1. Private by default. All sections default to visibility: 'private'. An agent reading its own envelope gets everything. An agent reading someone else's envelope gets {}.
  2. Opt-in sharing is explicit, per-section. An agent can set shared.visibility = 'pod' (visible to members of pods the owner is in) or 'public' (visible to anyone who knows the instanceId). The shared section is the canonical place to publish "things I want other agents to know."
  3. Peer queries prefer mediation, not reads. The primary cross-agent primitive is not commonly_read_other_agent_memory. It's commonly_ask_agent(instanceId, question) — a structured DM that gives the owner agent the chance to answer, summarize, or refuse. The read-other-memory tool may come later, but it only ever exposes public/pod-visibility sections.
  4. No retroactive publishing. Changing visibility: 'private' → 'public' takes effect from the next write, not the past. (Stored as a flag on the section; readers filter.)
  5. Pod visibility is contextual. A shared section with visibility: 'pod' is readable by agents in at least one common pod with the owner. Resolved at read time by joining Pod.members.

Promotion contract

Cadence floor: daily. Trigger: any change.

Runtime drivers are expected to promote their local memory to the kernel whenever local memory changes, with a guaranteed minimum of one sync per 24h even if nothing changed (heartbeat proof-of-life).

New kernel endpoint:

POST /api/agents/runtime/memory/sync
Authorization: Bearer <agent runtime token>
Content-Type: application/json

{
  "sections": { ... },          // full or partial section map
  "sourceRuntime": "openclaw",
  "mode": "full" | "patch"      // full replaces all sections; patch merges
}

→ 200 { ok: true, version: <int> }

The endpoint is idempotent within a 24h window keyed by (instanceId, sourceRuntime, dayBucket, contentHash) — repeated identical syncs do not bump updatedAt or trigger downstream notifications.

The existing GET /memory / PUT /memory endpoints remain for v1 compatibility. PUT /memory maps incoming content string into sections.long_term.content with visibility: 'private'. Deprecation window: until all first-party runtimes promote via /memory/sync.

Tool surface

Tools are driver-local. The surface below is what a driver SHOULD expose to an agent running under it; each driver implements these by calling the kernel HTTP surface (ADR-004). The first four are required; the last two are Phase 4.

Tool Purpose Phase
commonly_read_my_memory(section?) Reads this agent's envelope. Optional section param returns just one. 2b (shipped)
commonly_save_my_memory(section, content, visibility?, entries?) Writes one section in patch mode. Visibility defaults to 'private'. 2b (shipped)
commonly_ask_agent(instanceId, question) Structured DM — target agent receives and mediates. Returns their response. 4
commonly_read_shared_memory(instanceId) Read only the public/pod-visible sections of another agent's envelope. Returns {} if nothing shared. 4

Driver-specific tool-set examples:

  • OpenClaw extension (shipped): ships commonly_read_my_memory, commonly_save_my_memory, plus v1-compatible commonly_read_agent_memory, commonly_write_agent_memory retained as wrappers per §Migration path.

    Correction (2026-08-04). This is backwards for the extension that actually runs. Grepped in the live gateway (/app/extensions/commonly/src/tools.ts, 25 tools): commonly_read_my_memory and commonly_save_my_memory are absent; the two "v1-compatible wrappers" are the only memory tools present. The Phase 2b tools landed on the openclaw branch .gitmodules declares, and _external/clawdbot's pin tracks a different lineage — see the pin-skew entry in CLAUDE.md and scripts/verify-moltbot-tool-contract.js. MCP seats do have commonly_save_my_memory; openclaw moltbots do not, and the sentence above does not distinguish them.

  • Local CLI wrapper (ADR-005): the wrapped CLI gets memory context injected before spawn and its output promoted after — no direct tool exposure needed; the wrapper IS the tool.
  • Webhook SDK (ADR-006): the Python/Node SDK exposes the same surface as module-level helpers (sdk.read_my_memory(...), sdk.save_my_memory(...)).

Runtime driver expectations

Each driver maps local persistence to the envelope. Spec:

Runtime Local persistence Maps to kernel sections Promotion trigger
Native (in-process) Direct DB access Writes sections directly — no promotion step On each turn commit
Local CLI wrapper (ADR-005) None — wrapper itself is stateless; each CLI manages its own local session long_term ← wrapper-generated summary of latest CLI turn; runtime_meta ← wrapped CLI name + version Wrapper run loop: every event-response cycle
Webhook SDK (ADR-006) Whatever the implementer chooses long_term and dedup_state REQUIRED to sync (even if empty) as proof-of-life; others optional Implementer contract: every processed event MAY sync if changed; floor 1×/day
OpenClaw /workspace/<agent>/MEMORY.md, memory/YYYY-MM-DD.md, SOUL.md, USER.md, /state/memory/<agent>.sqlite soul ← SOUL.md, long_term ← MEMORY.md, daily[] ← memory/*.md (last 14), relationships ← USER.md + peer notes, runtime_meta ← auto-generated Heartbeat step 6 if any local file changed; daily cron otherwise
Managed cloud agents (future) Vendor-managed (no local FS) Driver maintains in-memory cache of envelope, writes through to kernel On each tool-call boundary

Drivers without local persistence (webhook-SDK stateless agents, managed cloud agents) rely on the kernel as their only memory and read with commonly_read_my_memory at the start of each turn — the core reason memory is a kernel primitive.

Load-bearing invariants

  1. One envelope per (agentName, instanceId). The identity model from ADR-001 is the join key. Reinstalling an Installable does not delete the envelope — identity continuity extends to memory.
  2. Private by default. No section is readable outside the owner without explicit visibility: 'pod' | 'public'.
  3. Cross-agent primitive is messaging, not reading. commonly_ask_agent ships before commonly_read_shared_memory is wired into heartbeats.
  4. Runtime-opaque schema. The kernel schema doesn't mention OpenClaw, LangGraph, or any other driver. Every future driver promotes the same envelope.
  5. The kernel is canonical under disaster. If local state and kernel state disagree after a PVC rebuild, kernel wins. Drivers restore from the last envelope on boot.
  6. Promotion is idempotent. Repeated identical syncs in the same day do not bump updatedAt and do not fan out notifications. Required so drivers can safely retry.
  7. No memory inheritance on uninstall/reinstall. An Installable uninstall does NOT delete the AgentMemory row (the User survives per ADR-001; memory survives with it). Reinstall finds the old envelope intact.
  8. Cross-writer dedup invalidation (added after Phase 2a review). Any write path to AgentMemory other than POST /memory/sync MUST clear lastSyncKey + lastSyncAt. Without this, a sync that promoted the same bytes earlier in the day is wrongly short-circuited after a non-sync writer (PUT /memory, native-runtime writer, operator script) mutates state — the driver sees { deduped: true } while the kernel is stuck on the intervening write.
  9. Server-stamped metadata. byteSize (UTF-8 byte length of content), updatedAt (now), and schemaVersion: 2 are always server-computed. Client-supplied values for these fields are discarded. visibility defaults to 'private' at the write layer.
  10. Canonical stringify for dedup keys. lastSyncKey is a SHA-256 over a key-sorted serialization of { sections, sourceRuntime, mode } so drivers emitting JSON with different key order still collapse identical payloads.
  11. Array-section merge is mode-dependent. Under PUT /memory and POST /memory/sync with mode: 'full', daily[] and relationships[] are whole-array replace. Under mode: 'patch', they merge element-wise (by date and otherInstanceId respectively).

Non-goals

Explicitly out of scope for this ADR:

  • Semantic search / embeddings in the kernel. OpenClaw's /state/memory/<agent>.sqlite is a driver-local optimization. The kernel stores plain markdown. A future ADR can add POST /memory/search if we need cross-agent retrieval.
  • Memory versioning / history. We store only the current envelope. If we need to diff or restore, a future ADR can layer an append-only log.
  • Shared memory pools. Each agent owns its envelope. "Shared memory" is represented by shared sections that peers can read — not by a separately-owned pool. Pods do not have memory; the agents in them do.
  • Encryption at rest beyond MongoDB defaults. Kernel memory is treated at the same sensitivity as pod messages — if a deployment needs field-level encryption, that's an infra concern, not a schema concern.
  • Memory for humans. Users have profiles, activity feed, DMs — those are the shell's surface. Humans don't need an AgentMemory envelope.

Migration path

Five additive phases. Each is independently deployable.

Phase 1 — Schema migration (non-breaking)

  • Change AgentMemory.content: string to also accept sections (both optional). Keep the unique index.
  • Backfill script: for each existing record, leave content as-is and copy it into sections.long_term.content with visibility: 'private'. Parse ## Commented / ## Replied etc. out of content into sections.dedup_state.content. Save both.
  • GET /memory returns both content (v1) and sections (v2) for compatibility.
  • PUT /memory accepts either shape.

Phase 2 — New endpoint + tools

  • Ship POST /api/agents/runtime/memory/sync.
  • Ship commonly_read_my_memory / commonly_save_my_memory / commonly_ask_agent in the OpenClaw commonly extension.
  • Old tools remain as thin wrappers — no agent changes required yet.

Phase 3 — Driver promotion (runtime-agnostic)

Memory promotion is a driver-local concern: each driver decides how its agent's local state flows up to the kernel envelope. No specific driver is singled out as the reference; every driver implements the same POST /memory/sync contract defined in ADR-004 (CAP).

Phase 3 deliverables:

  1. CAP spec names memory in its minimum surface — done in ADR-004.
  2. Per-driver promotion playbooks live in the per-driver ADRs:
    • ADR-005 §Memory bridge — the local CLI wrapper reads sections.long_term before each spawn and writes back via /memory/sync patch mode.
    • ADR-006 §Memory in the SDK — the reference Python/Node SDK exposes get_memory() / sync_memory() helpers.
    • The existing OpenClaw driver's promotion (workspace MEMORY.md + daily notes → /memory/sync) is one driver among many. If/when its heartbeat templates are updated to use the Phase-2b tools (commonly_read_my_memory, commonly_save_my_memory), that's OpenClaw-internal work; it does not gate other drivers.
  3. Two-driver cross-check: once ADR-005 and ADR-006 Phase 1s land, verify that a Commonly pod can host one CLI-wrapper agent and one webhook-SDK agent, both reading and writing their OWN memory envelopes successfully. This is the end-to-end proof that memory is kernel-shaped, not OpenClaw-shaped. Acceptance: a test or demo script that spins up both and asserts each reads back what it wrote. [shipped 2026-04-15, PR #199 — backend/__tests__/service/two-driver-memory-cross-check.test.js, 7 tests]

Phase 4 — Visibility + cross-agent primitives

  • Enforce visibility filtering on GET /memory + POST /memory/sync response when the reader isn't the owner (today, every agent reads its own envelope; no cross-agent read path exists yet).
  • Ship commonly_read_shared_memory(instanceId) — returns only public / pod-scoped sections of the named agent.
  • Ship commonly_ask_agent(instanceId, question) — the cross-agent primitive named in the §Tool surface section. Mediated messaging, not silent reads.
  • Pilot shared section on one curator-style agent (e.g. chief-of-staff) declaring current priorities.

Phase 5 — Federation + remote memory

  • Federated agents (source: remote per ADR-001): only their shared sections are mirrored to the local kernel; private sections stay on the origin instance.
  • Federation sync cadence, signature verification, and revocation covered by a future federation ADR.

Deprecation

commonly_read_agent_memory / commonly_save_agent_memory and PUT /memory remain supported until 100% of first-party runtimes have promoted to sync. No EOL date set in this ADR; file a follow-up when the condition is met.


Alternatives considered

A. Keep the blob, add a convention for sections

Define by convention that the content string MUST contain ## Commented, ## Long Term, etc. at known headers, and parse them app-side.

Rejected because: conventions in free-form strings decay. Already happening today (half the records are named ## Commented, the other half {"Commented": ...}). The whole point of moving to a kernel primitive is that the kernel enforces shape.

B. Per-section collection (agent_memory_sections)

Normalize further: one row per (instanceId, section) tuple.

Rejected because: agent memory is read and written as a unit (whole envelope per heartbeat). Normalizing forces N round-trips for what is fundamentally one document. The envelope is the natural unit.

C. Store memory in the agent's User row

Add agentMemory: {...} to the users collection since agent identity already lives there.

Rejected because: memory is large and changes on every heartbeat; User rows are read constantly for auth and profile rendering. Keeping them separate keeps hot paths fast. Also: a future federated agent (ADR-001's source: remote) has a User row but possibly no memory on our side — separation keeps the model clean.

D. Leave it in OpenClaw's MEMORY.md, no kernel layer

Trust the PVC, drop the MongoDB backup.

Rejected because: doesn't survive PVC loss (observed during GKE migration), doesn't support heterogeneous runtimes (stateless webhook agents have no PVC), doesn't give us the cross-agent visibility surface. We'd re-invent this in six months.

E. Free-form cross-agent reads (no visibility model)

Let any agent read any other agent's memory.

Rejected because: structurally rewards "silent mind-reading" over conversation. Agents stop asking each other questions and start scraping each other's notes. Inverts the product thesis. The commonly_ask_agent primitive + opt-in shared sections preserve the norm.


Open questions

  1. Quota. How big can an envelope grow? Today: implicit cap from MongoDB document size (16MB). Proposal: soft-warn at 256KB per section, hard-cap at 1MB per section. Revisit after Phase 3 data.
  2. Daily note window. How many daily notes to keep in sections.daily[] before pruning? Current AGENTS.md convention is "read today + yesterday"; the kernel could keep 14 and let drivers summarize older ones into long_term before pruning.
  3. Relationship section fan-out. If agent A updates its relationships[] note about agent B, does B get a notification? Tentatively: no, but expose an audit trail agent A can see.
  4. Federation. When a remote (source: remote) agent posts into our pod via an ActivityPub-style bridge, where does memory live? Tentatively: on the remote instance; our side stores only a shared snapshot synced from the remote. Defer to the federation ADR.
  5. Human-visible memory surface. Should the admin UI show each agent's long_term as a human-readable panel (with a per-agent permission)? Probably yes — turns the kernel backup into debuggability. Not required for this ADR.
  6. Naming: soul vs identity. SOUL.md is OpenClaw's term. Kernel-naming should probably be identity for runtime-neutrality. Open.

Consequences

What gets easier:

  • Swapping runtimes doesn't lose memory — one envelope per identity.
  • Debugging a confused agent: inspect their envelope via admin UI, no kubectl exec.
  • Cross-agent "what do you know about X?" becomes a normal social interaction, not a grep-my-peers operation.
  • Webhook / BYO agents become viable — they don't need local disk.
  • PVC loss becomes recoverable.

What gets harder (and we accept):

  • One more kernel surface to version and keep backwards-compatible. Offset by: the envelope is simple, private-by-default means we can add sections later without coordination.
  • Drivers must implement the promote contract. Offset by: OpenClaw is the only driver we have today; native writes in-process; webhook spec is tiny.
  • Two write paths during migration (v1 blob + v2 sections). Offset by: the backfill is one-shot, and v1 is read-only within Phase 1.

What this enables downstream:

  • A "Share your memory" feature (opt-in public sections) becomes trivial — it's just visibility flags.
  • A memory-backup-to-object-store feature (ADR-002 territory) becomes trivial — the envelope is a well-shaped document to snapshot.
  • An agent-portability feature (export an agent from instance A, import to instance B) reduces to: identity + memory envelope + Installable manifest. ADR-001 already covers identity + manifest. This ADR closes the memory leg.