Skip to content

Keep memory in Loom, through the MCP gateway - #546

Merged
pufit merged 21 commits into
mainfrom
pufit/loom-memory-mcp
Oct 22, 2026
Merged

pufit merged 21 commits into
mainfrom
pufit/loom-memory-mcp

Conversation

@pufit

@pufit pufit commented Oct 21, 2026

Copy link
Copy Markdown
Member

Replaces #535. With memory.backend: loom, long-term memory is kept in a Loom memory service instead of memU, and everything that talks to Loom goes through the MCP gateway that external mode already uses: the gateway's catalog lists Loom as the server loom, knows the agent from its connection and holds its Loom credential. Nerve gets no Loom URL or token of its own; memory.backend: loom plus the existing mcp_gateway_url is the whole configuration.

memU stays the default. Its tools, its system prompt (checked against a frozen copy of main's rendering), its templates and its jobs render as before. Two texts memU sessions also see did change: the worker guide, which the worker onboarding prompt carries in full, has a bullet on Loom, and the bundled nerve-dev skill's source map names the Loom backend.

Rollout: switch an instance to memory.backend: loom only once the gateway's catalog lists loom for its agent; until then the agent has no memory tools at all, and its system prompt says so. A platform that switches the instance after its first nerve init writes memory.backend: loom into config.local.yaml (which outranks the tracked settings) and passes nerve init --memory-backend loom, so the files written at that init are worded for Loom. A locked instance reads no machine-local file: its tracked settings.yaml names the switch as memory.backend: ${NERVE_MEMORY_BACKEND:-memu}, mcp_gateway_url: ${NERVE_MCP_BASE_URL:-} and memory.loom.dashboard_url: ${LOOM_DASHBOARD_URL:-}, always with a default (a bare ${VAR} that is unset refuses to load, which would break nerve commands run from a shell), and the platform provides the variables in the Nerve service's environment (a systemd EnvironmentFile= on the unit); empty values mean memU, no gateway and no link.

What the agent gets

Loom's own tools, the same way it gets any catalog server: mcp__loom__loom_search, mcp__loom__loom_memory_set, … (whatever the gateway's tool policy allows, by default Loom's core list). Nerve's nine memU-shaped memory tools (memorize, memory_recall, memory_update, memory_delete, conversation_history, memory_records_by_date, memory_expand_category, category_update, session_context) are not offered under Loom: not in the prompt's tool list, not on Nerve's MCP server, not to external MCP clients.

Every place that named those tools has a Loom branch (nerve/memory/loom_prompts.py):

  • a system prompt section naming Loom's tools and explaining home vs. team/org namespaces (shared memories are read-only for the agent; it proposes a change with proposed: true where it is a writer, or tells the human), Loom's write verdicts (clear, merge, duplicate, conflict, refines, rejected/PINNED_CONFLICT), searches that abstain, and that facts extracted from conversations are found with loom_search. It is given only to a session that gets the loom server; any other session is told memory cannot be used;
  • the worker onboarding step, the Codex backend note (like the system prompt section, it names Loom's tools only for a client that gets the loom server), the bundles written for external agents;
  • the workspace templates (AGENTS.md, TOOLS.md, MEMORY.md), the bundled nerve-dev skill, and the memory-maintenance and inbox-processor jobs of a workspace set up while Loom is the backend or by nerve init --memory-backend loom (the maintenance job becomes Loom's integrity report, loom_namespace_validate, on the home namespace). A system.yaml written for memU runs those two jobs in their Loom wording as long as their prompts are the shipped ones, so memU's maintenance prompt never prunes through Loom's tools.

The section depends on the configuration and the catalog only, so prompt caching is unaffected.

What Nerve calls itself

Only lifecycle calls, as the same agent, with tools/call on the catalog URL of loom (stateless streamable HTTP, no credential). A small tool-call client (McpGatewayServer) joins the existing catalog reader in nerve/mcp_gateway.py.

When Loom op MCP tool
A new session starts (5 s) context.pack (startup mode, memories section only) loom_context_pack
… the pack brought no memories, or the policy refused it search.query loom_search
Session close, rotation, indexing sweep memory.set_from_messages (defer: false: the call waits while Loom extracts; batches of ≤100 messages within 128 KiB; up to 130 s) loom_memory_set_from_messages
Identity and health (every 5 min, Diagnostics) namespace.summary loom_namespace_summary

Waiting for the extraction is what keeps a conversation safe: a batch Loom could not extract fails the call and the sweep keeps its watermark. memory.loom.defer_extraction: true queues the batches instead (≤400 messages within 768 KiB); Loom then tries a failed batch three times within about a minute and gives up, and how a batch ended is not on MCP, so it stays off until Loom serves the extraction status over MCP and Nerve follows up on it.

Loom's MCP has no whoami; namespace.summary names the org and namespace Loom resolved for the agent (memory.loom.org / memory.loom.namespace override it; both are checked against Loom's naming). The startup recall is frozen like memU's. Recalled text is data from whoever writes to the namespaces the agent reads, and it lands in the session's <session-context> block: each memory is one line and keeps </> escaped as Loom escapes them, and the fallback search's text is escaped the same way.

When Loom cannot answer

No gateway URL, no loom server in the catalog, a gateway policy refusal (-32001), a condition that may pass (-32002: unavailable, limits, open circuit), a server fault, a timeout or 429/5xx: memory is unavailable and nothing crashes. A new session's preamble says memories could not be recalled (not that there are none), also when the backend did not start; the sweep keeps its watermark. Reads are retried once and wait out a Retry-After of up to 2 s (the gateway's per-agent limit sends 1); a longer one holds the op. When the gateway is not reached, or a call gets no answer in time (a read gives up long before the gateway's call limit, extraction a little after it), a breaker opens for 15 s, doubling while failures go on, up to 5 min: the gateway's own circuit never counts calls Nerve gave up on. The catalog is read at most every 30 s, also while none could be read; refusals keep the one HTTP client for Loom's URL, replaced only when the catalog names another. A batch the gateway refuses as too large (413), or gives up on after its call limit (MCP_GATEWAY_CALL_TIMEOUT, "MCP server timed out"), halves the batch budget, down to 10 messages in 16 KiB, with the context turns shrinking too. A coded Loom fault (its database unreachable, a grant busy, an overload) is "not now". Only Loom's uncoded internal error can be about the batch itself: a batch failing with it three sweeps in a row is halved until the one message it fails on is found; that message is left out of the next batch and its context, and skipped (audit row with role and time, never text) only when that next batch goes through, so a failure first, or a Loom failing every batch, skips nothing. A budget a timeout cut grows back after ten batches in a row go through; Nerve's own timeout cuts it too. A window offered again sends only the messages Loom has not taken yet. The Diagnostics card shows the last error.

UI, setup, doctor

  • Memory page: "Memory is kept in Loom" with a link to Loom's dashboard when memory.loom.dashboard_url is set (only absolute http(s) links are rendered); the memU edit routes answer 409.
  • Diagnostics: a Loom card with the gateway, org, namespace (and where it came from), last error and check time.
  • Setup: the Memory step has nothing to ask under Loom; the Mode step writes the Loom wording. nerve init --memory-backend memu|loom words the files the init writes (slim, --terminal and --non-interactive); without it, init follows what an existing configuration names.
  • nerve doctor names Loom and the gateway, or says the gateway URL is missing.

Tests

  • tests/test_loom_memory.py: the backend against a fake gateway (GET /catalog + POST /s/loom/mcp): startup pack and its fallback, a stored fact that tries to close <session-context>, extraction batching, waiting and queueing, body limits and 413, identity, health, every degraded mode (breaker backoff, catalog throttle, Retry-After, policy refusals), the engine's preamble note, tool guidance per catalog and sweep watermark, routes and doctor.
  • tests/test_mcp_gateway_tools.py: the tool-call client (JSON and event-stream answers, JSON-RPC and HTTP errors, retryability, read timeouts vs. unreachable, no credentials).
  • tests/test_loom_contract.py: every argument on the wire and every result field read is checked against tests/fixtures/loom_lifecycle_ops.json, generated from a Loom checkout by scripts/loom_contract_fixture.py (Loom's generated SDK op schemas plus its MCP toolsets), never typed by hand. The generator also refuses an op Loom keeps off MCP and any tool the agent-facing wording names that Loom does not list by default.
  • tests/test_loom_prompts.py: every Loom branch, memU's system prompt against main's frozen rendering, shipped memU jobs reworded under Loom.
  • tests/test_cli_init_slim.py: --memory-backend in the slim, terminal and non-interactive paths.
  • Web: Memory page, Diagnostics card, setup Memory step, link helper.

Known gaps

  • Two bad messages that land in the same batch (for example a user message and the reply quoting it) are not yet isolated from each other: the window stays unindexed, nothing is lost, and a follow-up will count faults for the batch after a held message as well.
  • Following up on queued extraction. memory.extraction_status is kept off MCP (it is refused even through loom_op), so the queued mode cannot be followed up; it stays behind memory.loom.defer_extraction (off). A Loom change adds two caller-bound ops reachable through loom_op, memory.extraction_status_mine and memory.extraction_retry_mine ({"queue_ids": [...], "namespace"?}); once that release exists, the queued mode can move the sweep's watermark only after status_mine reports a batch written (done), and replay a failed one once with retry_mine (a TODO in nerve/memory/loom.py says so).
  • Loom tools outside external mode. Catalog servers reach the agent only in external mode; with the Loom backend in local mode the agent has no memory tools, its prompt says so, and Nerve logs a warning at startup.
  • Workspace templates are not gated on the catalog. AGENTS.md, TOOLS.md, MEMORY.md and the nerve-dev skill are written once, at setup, worded for Loom's tools whatever the catalog lists then or later. A session without the loom server is told by its system prompt (and the Codex note) that memory cannot be used, which is what it acts on.
  • Writes are not retried on a short Retry-After. Only reads wait it out; an extraction refused by the gateway's per-agent limit fails, and the sweep offers the window again on its next run.
  • No pending write verdict in the prompt. Loom's write verdicts are clear, merge, duplicate, conflict, refines and rejected; pending is not one of them, so the system prompt does not name it.
  • A batch the gateway gave up on may be extracted twice. Loom keeps working on a call after the gateway's limit; the next sweep sends the window again in smaller batches, and Loom's merge and duplicate verdicts are what keep the repeat from adding facts twice.

Generated by Nerve

pufit added 21 commits October 21, 2026 14:13
MemoryBackendUnavailable is part of the memory contract, not of memU. It
moves to nerve.memory.backend, so a module can name it without loading
memU's numpy stack; memu_bridge re-exports it.
memory.backend picks memu (the default) or loom; an unknown name refuses to
load. memory.loom holds the optional org, namespace and dashboard_url, and
is checked only while Loom is the backend. Nothing comes from the
environment: Nerve reaches Loom through the MCP gateway it already knows.
McpGatewayServer sends one tools/call per request to a catalog server's URL,
as the gateway serves it (stateless streamable HTTP, protocol 2025-06-18),
and sorts what can come back: a tool result, a tool that refused, a JSON-RPC
error the gateway or server answered (-32001 is a policy decision, -32002
and -32000 may pass), or no answer at all.
With memory.backend: loom the engine builds LoomMemory, which reaches Loom as
the 'loom' server of the gateway's catalog and makes only the lifecycle
calls: the startup recall (context.pack in startup mode, loom_search when the
pack brings no memories), transcript extraction (memory.set_from_messages,
deferred; the audit log names each queued batch) and a health probe
(namespace.summary, which also names the org and namespace Loom resolved for
the agent). Agents use Loom's own tools from the catalog, so Nerve's nine
memory tools are not offered then.

Gateway and Loom failures degrade instead of crashing: a session that could
not recall is told memory is unavailable, nothing is frozen, and the sweep
keeps its watermark.
scripts/loom_contract_fixture.py reads a Loom checkout (the generated SDK's
op schemas and the MCP toolsets) and writes what Nerve uses of them to
tests/fixtures/loom_lifecycle_ops.json. The contract tests hold the backend
to it: each op is called through the tool that serves it on MCP, and every
argument on the wire and every result field read is one Loom declares.
Every place that names Nerve's memory tools gets a Loom branch, worded in
nerve/memory/loom_prompts.py: a memory section in the system prompt (Loom's
tools, home and shared namespaces, proposing instead of editing, write
verdicts and abstentions), the worker onboarding step, the Codex backend
note, the bundles written for external agents, the workspace templates and
bundled skills a Loom instance is set up with, and the memory-maintenance
and inbox-processor jobs. Under memU each stays byte for byte as it was.

The contract fixture also records the tools this wording names; the
generator refuses one Loom does not list by default. An instance that is
not in external mode gets no gateway catalog, so its agents have no Loom
tools: the engine says so at startup.
With memory.backend: loom there is no memU store: the Memory page says
memory is kept in Loom and links Loom's dashboard when
memory.loom.dashboard_url is set, the memU edit routes answer 409, and the
Diagnostics card shows the gateway, the namespace, where it came from and
the last error. The setup Memory step has nothing to ask (no OpenAI key)
and nerve doctor names Loom and the gateway. A link is only built from an
absolute http(s) address.
config.md gets memory.backend and the memory.loom keys, what agents and
Nerve each call, how identity is resolved and what happens when Loom cannot
answer; memory.md a Loom section; the guides, cron, setup, web UI, external
MCP, Codex sync and architecture pages say what changes under Loom.
The recalled memories go into the session's <session-context> block (and the
system prompt in the legacy shape), and their text comes from whoever writes
to the namespaces the agent reads. The startup recall undid Loom's escaping
of < and > and kept an item's line breaks, so a stored fact could close the
block and start lines of its own. Pack items now keep Loom's escaping, the
fallback search's text is escaped the same way, and each item is one line.
Queued extraction could lose a window: the sweep moved its watermark once
Loom queued a batch, and Loom gives a failed batch three tries within about
a minute, out of Nerve's sight. The call now waits while Loom extracts
(batches of up to 100 messages in 128 KiB, 130 s), so a failure keeps the
watermark; memory.loom.defer_extraction keeps the queued mode. A batch the
gateway refuses as too large halves the batch budget and goes again.

The startup read has 5 s. A gateway not reached, or a read not answered in
time, opens a breaker that doubles while failures go on (the gateway's own
circuit does not count calls Nerve gave up on). The catalog is read at most
every 30 s, also while none could be read; a read waits out a Retry-After of
up to 2 s once; a pack the gateway's policy refuses falls back to the search.
One server object at a time, a retired one closed at shutdown. The unused
identity helper goes, memory.loom.org and namespace are checked against
Loom's naming, and the contract fixture is regenerated against Loom's
current op schemas. The contract now covers the written list the
extraction counts, and the fixture keeps only what Nerve uses: the values
it sends of a fixed set, and the fields of an answer it reads.
The system prompt named Loom's tools whenever Loom was the backend, also for
a session whose client got no loom server (local mode, or a catalog that
does not list loom yet). Such a session is now told in one section that
memory cannot be used. A session whose Loom backend did not start at all (no
gateway URL) is told memory is unavailable, as for an outage. The section
also says that facts extracted from conversations are found with
loom_search. memU's system prompt is checked against a frozen copy of main's
rendering, and the claims that memU's texts are unchanged now name the two
it sees that did change.
A platform runs its first nerve init before it switches the instance to Loom
(memory.backend: loom in config.local.yaml), so the files that init writes
were worded for memU. nerve init --memory-backend memu|loom words the
templates, bundled skills and built-in jobs for the backend it names, in the
slim, terminal and non-interactive paths; it does not set memory.backend.
Without it, init follows what an existing configuration names.

A system.yaml written for memU keeps its jobs when the backend changes:
memory-maintenance and inbox-processor whose prompts are still the shipped
memU ones run in their Loom wording, so memU's maintenance prompt does not
prune memories through Loom's tools.
config.md: memory.loom.defer_extraction, the name rules, the rollout order
(switch once the catalog lists loom; nerve init --memory-backend for a
platform that switches after its first init), waiting extraction, the
escaping of recalled text, and the timeouts, breaker, Retry-After and 413
handling. memory.md, cron.md and setup.md follow.
The note names Loom's memory tools only for a client that gets the loom
server from the gateway's catalog, like the system prompt's memory section;
any other client's note names no memory tools at all.
A TODO where the queued mode is chosen names the caller-bound status and
retry ops a Loom change adds, and how the queued mode can use them.
…ction

A policy refusal (-32001) retired the HTTP client for Loom and made a new
one, and retired clients were closed only at shutdown: a gateway that
answers "no credential set" until the agent's credential exists leaked a
client and its socket on every health probe, session start and sweep. The
client is now kept across refusals; the catalog is read again, and the
client is replaced only when Loom's URL in it changed, the old one closed as
soon as no call uses it.

An extraction waits a little past the gateway's own call limit, so no answer
by then means the gateway itself is stuck: it now opens the breaker like an
unanswered read, instead of making every session of the sweep wait 130 s
again while the memorize lock is held.
…s failing

When the gateway gives up on an extraction after its call limit ("MCP server
timed out"), Loom may still finish the batch, and the same batch would run
into the limit again: the batch budget now halves, messages and bytes, as
for a body too large, and the window goes again with the next sweep.

A batch Loom keeps failing on its side (a fault, -32000, it does not name as
passing, three sweeps in a row) held the rest of the window back forever and
paid for a model call each sweep. It is now halved while the halves fail,
down to the one message Loom fails on, which is skipped with an audit row
(its role and time, never its text) once Loom has extracted something else
since, so a fault on every batch, an outage, skips nothing. The context
turns shrink with the batch budget, so a budget at its minimum still fits.
An old install with no system.yaml has its built-in jobs in jobs.yaml; under
Loom, a memory-maintenance or inbox-processor job there that still carries
the shipped memU prompt now runs in its Loom wording too.
Lockdown reads no machine-local file, so a locked instance's tracked
settings name the switch as ${VAR:-default} references the service's
environment fills (memory.backend, mcp_gateway_url,
memory.loom.dashboard_url); a test loads such settings with and without the
variables. config.md also says how extraction depends on the gateway's call
limit, what happens to a batch Loom keeps failing, and that one client is
kept for Loom's URL.
The isolation of a failing batch could skip good messages, a whole window
even, while Loom failed for everyone: it took any extraction since the
batch's first failure, sweeps ago perhaps, as proof Loom worked, kept
isolating at once after a skip, passed the skipped message on as context,
and counted coded faults (BACKEND_UNAVAILABLE, GRANT_BUSY) as the batch's.

Now only Loom's uncoded internal error counts against a batch; every coded
fault is "not now". The one message found is left out of the next batch and
of its context, and recorded as skipped only when that next batch goes
through (at the end of the window: when a batch went through since the
isolation began in this sweep); a failure first raises and skips nothing.
After a skip the isolation is over. A window offered again sends only the
messages Loom has not taken yet, whatever its batches look like then, so a
re-sweep no longer extracts accepted messages again one by one.

A budget a timeout cut grows back toward the default after ten batches in a
row go through (a cut for a body too large stays), and Nerve's own timeout
cuts it like the gateway's.
config.md: only Loom's uncoded internal error counts against a batch, the
message found is skipped only once the next batch goes through and is never
context, a window offered again sends only what Loom has not taken, and a
budget a timeout cut grows back.
@nerveai

nerveai commented Oct 21, 2026

Copy link
Copy Markdown

Nerve PR review · ✅ Ready to merge

Adds Loom as an opt-in memory backend (memory.backend: loom) reached only through the existing MCP gateway as the agent itself. Nerve holds no Loom URL or credential, agents use Loom's own catalog tools, and Nerve keeps just the lifecycle calls (startup pack, transcript extraction, health). That is the right layering for external mode. memU stays the default and is pinned by a frozen-prompt test. A Loom outage raises MemoryBackendUnavailable, so the sweep keeps its watermark. Recalled text is escaped and put on one line before it enters <session-context>.

Completeness: memU tools are hidden on all three serving paths, and both backends are covered. The contract test checks Loom's op schemas. Docs, setup, doctor and Diagnostics are updated. No migration.

Worth considering:

  • A nerve reload that flips memory.backend applies only half the switch. Tool exclusion (nerve/agent/backends/base.py:252), the prompt (engine.py:1737) and the cron rewording follow the live config. The bridge (engine.py:829) and the routes (routes/memory.py:139) stay on the startup backend. On loom→memu, memU tools are served against a LoomMemory that has no recall. Keying these on the running bridge's backend closes it.
  • Onboarding is a conversation with the agent #547 adds a second onboarding prompt that names memorize (its nerve/agent/prompts.py:344). Whichever PR lands second needs a Loom branch for it.

Checked: full suite in an offline sandbox at 960128a in 3 chunks, with the Slack live test excluded because it hangs offline on main too: 7100 passed, 38 skipped. CI green. Read loom.py, the mcp_gateway.py client, and the engine, prompt, config, cron and route wiring. Loom itself is not reachable from here, so the wire format was checked only against the PR's fake gateway and fixture. Frontend not run locally (CI build green). · automated review by nerveai — mention @nerveai to ask a follow-up.

@pufit
pufit merged commit 6662701 into main Oct 22, 2026
4 checks passed
@pufit
pufit deleted the pufit/loom-memory-mcp branch October 22, 2026 14:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants