Skip to content

Migrating an Agent Framework's Memory Store Format

11 min read · updated August 11, 2026

Swapping agent frameworks is mostly a code change until you reach the stored threads. Those are user data: months of conversations, each one a sequence with invariants that the destination API enforces and your old store never did. A conversion that gets the message text right and the tool-call pairing wrong produces threads that load fine and fail on the next turn.

What is actually in a memory record

Frameworks differ in spelling, not in substance. Almost every agent persistence schema is some arrangement of these:

  • A thread identity — an id, an owner, timestamps, and usually a tenant. This is the part your application keys on and it must be preserved exactly, because every foreign key you have points at it.
  • An ordered message list with a role per entry. Ordering is semantic, not cosmetic; if your old store relied on an auto-increment id and the new one on a timestamp, two messages written in the same millisecond can swap.
  • Tool calls and their results, linked by an identifier. The assistant turn requests a call and carries an id; a later entry carries the result and references that id. Both the OpenAI-shaped and Anthropic-shaped APIs enforce that linkage, through tool_call_id and tool_use_id respectively.
  • Non-message state — scratchpad values, a plan, a step counter, retrieved documents, whatever your agent kept between turns. This is where frameworks differ most and where an automated conversion is least likely to exist.
  • Compaction artefacts — rolling summaries and the marker for how much of the raw history they replace.

The parts that do not survive

Write these down before you start, because each one is a decision, and a conversion script that makes them implicitly makes them wrong.

  • The system prompt’s home. Some stores keep it as the first entry in the message list with a system role. The Anthropic Messages API takes it as a top-level system parameter and does not accept a system role inside messages at all. Converting one to the other means moving a field, and converting back means deciding whether the current system prompt or the historical one is the truth. Prefer storing it separately and versioned in both directions.
  • Content shape. One family models message content as a string, another as an ordered list of typed blocks (text, image, tool use, tool result). Flattening blocks to a string loses the structure irrecoverably; inflating a string to a single text block is safe. Convert in the safe direction and keep the original.
  • Reasoning and thinking content. Where a provider returns internal reasoning as its own block type, that block is generally bound to the response that produced it — some are cryptographically signed, and none are meaningfully replayable into a different model. Do not attempt to carry them across. Keep them in an archive column for audit and exclude them from the replayable history.
  • Orphaned calls. Real stores contain assistant turns that requested a tool and never got a result, because the process died. Your old framework tolerated it. The destination API will reject the thread. You must decide: synthesise an error result, or truncate the thread at the last complete turn. Synthesising is usually right — a result whose content says the call failed keeps the history honest and loadable.
  • Timestamps and token accounting. Per-message usage figures rarely have a slot in the destination schema. Move them to your own analytics table before the conversion, not after, or you lose your cost history along with the format.

A canonical intermediate record

Do not write a direct A-to-B converter. Write a reader into a canonical record and a writer out of it. It is barely more code, it makes the lossy decisions explicit in one place, and it means the next framework change is one new writer rather than another whole conversion.

// canonical.ts — owned by you, not by either framework
export type CanonicalRole = "user" | "assistant" | "tool";

export type CanonicalBlock =
  | { type: "text"; text: string }
  | { type: "tool_call"; callId: string; name: string; args: unknown }
  | { type: "tool_result"; callId: string; isError: boolean; content: string };

export type CanonicalMessage = {
  seq: number;                 // authoritative order, assigned at read time
  role: CanonicalRole;
  blocks: CanonicalBlock[];
  createdAt: string;           // ISO 8601, UTC
  meta?: Record<string, unknown>;
};

export type CanonicalThread = {
  threadId: string;
  tenantId: string | null;
  systemPrompt: string | null; // NOT a message
  systemPromptVersion: string | null;
  messages: CanonicalMessage[];
  summary: { text: string; replacesUpToSeq: number } | null;
  state: Record<string, unknown>;
  sourceFormat: string;        // e.g. "framework-a@2"
  archived: unknown;           // anything the canonical form cannot hold
};

The two fields that earn their place are seq and archived. Assigning the order once at read time means the writer never has to re-derive it from timestamps. And a place to put whatever you could not model means the reader never has to silently discard something — you can go back for it later, which you will.

Validating the invariants

This is the step that separates a migration that works from one that works for eight days. Run it over every converted thread before you write anything.

export function validate(t: CanonicalThread): string[] {
  const errors: string[] = [];
  const open = new Map<string, number>();

  let last: CanonicalRole | null = null;
  for (const m of t.messages) {
    for (const b of m.blocks) {
      if (b.type === "tool_call") {
        if (m.role !== "assistant") errors.push(`seq ${m.seq}: tool_call on ${m.role}`);
        if (open.has(b.callId)) errors.push(`seq ${m.seq}: duplicate callId ${b.callId}`);
        open.set(b.callId, m.seq);
      }
      if (b.type === "tool_result") {
        if (!open.delete(b.callId)) {
          errors.push(`seq ${m.seq}: tool_result for unknown callId ${b.callId}`);
        }
      }
    }
    if (m.role === "user" && last === "user") {
      errors.push(`seq ${m.seq}: consecutive user turns`);
    }
    last = m.role;
  }

  for (const [callId, seq] of open) {
    errors.push(`seq ${seq}: tool_call ${callId} never resolved`);
  }
  if (t.messages.some((m, i) => m.seq !== i)) errors.push("seq is not dense and ordered");
  return errors;
}

Run it over the whole corpus first and count the error classes. The distribution tells you what your old data actually looks like, which is never what the schema says it looks like. Threads with unresolved calls are the population you have to make a policy decision about, and knowing there are 40 of them rather than 40,000 changes which policy is reasonable.

Running the backfill

  1. Snapshot the source store and take a copy you can read after the cutover. A conversion bug found in week two is recoverable only if the input still exists.
  2. Convert to canonical for the whole corpus, offline, first. Write the canonical records to their own table. Nothing is destructive at this stage and the validator output is your survey of the data.
  3. Replay one thread end to end against the new framework and the target API before converting the rest. Load it, send one real turn, and confirm the model sees the history — ask it something only an earlier turn answers. A thread that loads is not a thread that works.
  4. Backfill in batches, idempotently. Key each written record on the source thread id so a re-run overwrites rather than duplicates, and record a per-batch cursor. Assume the job will be interrupted, because it will be.
  5. Dual-read before you dual-write. Serve reads from the new store with a fallback to the old for any thread not yet converted. That makes the cutover continuous instead of a moment, and a bad batch degrades to the old behaviour rather than to an error.
  6. Keep the old store read-only for a defined window, then delete it deliberately. Set the window before you start, so it is a decision rather than an old table nobody dares drop.

Finally, keep a test that loads a fixture thread containing every awkward case — a tool call, a failed tool call, an image block, a compaction summary — and asserts the agent takes a correct next turn from it. That is the persistent version of the replay in step three, and it is the subject of testing agent memory persistence.