Skip to content

GET /agentmemory/export drops the iii worker once the response passes 16 MiB, 404ing every endpoint #1142

Description

@dmazhukov

Once a store grows past a certain size, GET /agentmemory/export stops returning and takes
the whole daemon down with it for about a second. The failure is a hard size limit on the
response, not a timeout, and it is fully deterministic.

What I see

$ curl -s -o /dev/null -w '%{http_code}\n' localhost:3111/agentmemory/export
500
$ curl -s localhost:3111/agentmemory/export
{"error":"Invocation stopped","error_id":"…"}
$ curl -s localhost:3111/agentmemory/export
{"error":"Function api::export not found","error_id":"…"}

The daemon log shows the export finishing before the connection dies:

[agentmemory] info Export complete {"sessions":15,"observations":12750,"memories":3000,…}
[iii] Reconnecting in 955ms (attempt 1)...
[iii] Worker registered with ID: 43829794-b900-4040-8351-58cf42d5ec61

Export complete is logged, so the data was assembled and the function returned. What dies is
the response on its way back. While the worker re-registers, every endpoint 404s, so a single
GET makes the entire REST surface unavailable for ~1s.

Expected

Either the export returns, or it fails on its own without disconnecting the worker and taking
128 unrelated endpoints with it.

The limit is 16 MiB, and it is a limit rather than a timeout

Bisected on a local instance by growing the store in ~27 KB steps and re-requesting a fixed
slice:

serialized response result
16 771 046 B (15.9941 MiB) 200 OK in 365 ms
next step, +27 KB 500 in 103 ms

16 MiB is 16 777 216 B, so the last success lands 6 170 B short of it — 0.037%. Note the
failing request returns faster than the succeeding one (103 ms vs 365 ms). Nothing is
timing out; the frame is refused.

Where the limit appears to live

I could not find it in this package. iii-sdk opens its WebSocket with no maxPayload, so
the ws client default of 100 MiB applies. The iii engine binary carries tungstenite's
WebSocketConfig field names (max_write_buffer_size, max_fragment_size), and
tungstenite's default max_frame_size is exactly 16 MiB, which matches the measurement.

This is an inference from strings in the binary, not something I read in engine source,
and I could find no config key or CLI flag that exposes it. If the engine can be configured
to raise it, that would be a second, independent fix.

One detail that matters for where a guard can go: api::export reaches mem::export through
sdk.trigger, so the result crosses the worker↔engine boundary twice. It dies on the first
hop, which is why the HTTP layer only ever sees Invocation stopped.

Why pagination does not currently help

?maxSessions=/?offset= slice sessions and the observations hanging off them. Every
other collection — memories, summaries, graphNodes, graphEdges, semantic,
procedural, actions, actionEdges, sentinels, sketches, crystals, facets,
lessons, insights, routines, signals, checkpoints, accessLog — is fetched in full
regardless. On my repro store ?maxSessions=1 still returned 5.50 MB, of which ~4.9 MB was
that unpaginatable floor.

That floor grows with the store. Once it alone crosses 16 MiB, export becomes impossible at
any parameter combination, with no way back — the same dead end #890 describes for
mesh/export.

Collateral: the drop leaves a KV call hanging

Every reproduction also left an index-persistence write in flight that never settled:

[agentmemory] warn audit write failed {"functionId":"mem::index-persistence",
  "operation":"index_persist","error":"Invocation timeout after 180000ms: state::set"}

That is the 180 s inherited worker default #1127/#1128 is about, reached because the worker
went away mid-call rather than because the call was slow.

Reproduction

Against a local instance with a scratch data dir (agentmemory --data-dir /tmp/scratch),
seed in rounds and probe after each:

const B = "http://localhost:3111/agentmemory";
const obs = (s, i) => ({
  id: `obs_${s}_${i}`, sessionId: s, timestamp: "2026-02-01T10:00:00Z",
  type: "file_edit", title: `Edit ${i}`, facts: [`fact ${i} `.repeat(6)],
  narrative: `narrative ${i} `.repeat(12), concepts: ["auth"],
  files: [`src/f_${i}.ts`], importance: 5,
});

for (let round = 1; round <= 8; round++) {
  const sessions = [], observations = {};
  for (let s = 0; s < 5; s++) {
    const id = `ses_r${round}_${s}`;
    sessions.push({ id, project: "p", cwd: "/tmp", startedAt: "2026-02-01T00:00:00Z",
                    status: "completed", observationCount: 850 });
    observations[id] = Array.from({ length: 850 }, (_, i) => obs(id, i));
  }
  await fetch(`${B}/import`, {
    method: "POST", headers: { "content-type": "application/json" },
    body: JSON.stringify({ exportData: {
      version: "0.9.28", exportedAt: new Date().toISOString(),
      sessions, observations, memories: [], summaries: [] }, strategy: "merge" }),
  });
  const r = await fetch(`${B}/export`);
  const body = await r.text();
  console.log(round, r.status, (body.length / 1048576).toFixed(2) + " MiB");
}

Reproduced 6/6. On my machine it flips at round 3.

Environment

  • agentmemory 0.9.28 (npm view @agentmemory/agentmemory version0.9.28)
  • Node v26.5.0
  • macOS 26.5.2, arm64
  • native iii engine 0.11.2 (the pinned version), store_method: file_based

Related

#890 (mesh/export unpaginated, same Invocation stopped), #1124 (a single request
unregistering the whole worker), #1100 (a fan-out that wedges an endpoint), #195 (event-loop
freezes), #1127/#1128 (the 180 s KV default the hanging call reaches).

A PR follows this issue: refuse the dump before it reaches the transport and return 413
with the paging parameters, plus extend paging to the other collections so a large store stays
exportable. Happy to reshape it if you'd rather solve this a different way.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions