AI agents

Rewinding memory

Work out how far back an AI agent's memory reaches, read what a rewind to the moment you name would contain before you run it, rebuild that memory beside the live one, and carry forward the work the agent did after that moment.

Table of Contents

A fetched page or a tool result can write a fact into an AI agent's memory that nobody meant to store, and every run after that write reads it. Deleting the row you found leaves behind whatever the agent wrote while recall still returned it, so a delete on its own will not finish the repair. A systematic study of memory poisoning reports that 'a single adversarial memory write can exert long-term influence over agent behavior' and that 'existing prompt injection defenses fail to cover memory poisoning attacks' (arXiv 2606.04329).

planner-agent works for a freight company, and the memory below is its own. That memory holds one row for each fact the agent learned, and recall returns the newest row for a subject, which makes the row a fetched page wrote the one the agent answers from.

idsubjectvaluesource
1refund requestsaccounts@northwind-freight.examplefinance-policy
2the Leeds warehousecloses at 17:00checkWarehouseHours
3refund requestsfinance@vendor-update.examplefetched-page
4the Antwerp warehousecloses at 16:00checkWarehouseHours
Row 3 came from a page the agent fetched, and row 4 is work the agent did afterwards that nobody disputes.

Sirannon backs a database up as it writes, which leaves the rows the agent held before that write at your destination. planBackupRestore reads those backups and tells you what a rewind to any moment would contain before you run it. Each code block below holds one whole file.

Watch the agent repeat what the page told it

openMemory clears the data directory, opens the agent's database with the backups option, and creates the fact table. Every run of this page therefore starts from the same state and builds the same chain. Sirannon copies the whole file once and then sends the write-ahead log frames written since the previous cycle. The continuous backups page covers that cycle, and the destinations page covers writing the pieces to storage you choose. backup-store.ts keeps its pieces in a Map, which is what lets this page run with no storage account behind it. In production, connect the object store you already use.

learn pauses before it writes each fact, and it captures a backup cycle straight after the write. Sirannon therefore writes every fact into a piece of its own and stamps each piece at a different moment. That pause is here so that one file can show a rewind. Backups taken on an interval already differ in time, and your own code takes no pause of any kind.

The mock is here so that the panels below print the same text on every run, and a model string in its place lets a real model choose.

import type { Database } from '@delali/sirannon-db'
import { ToolLoopAgent } from 'ai'
import { openMemory, sirannon } from './memory-store'
import { brainTools, remember } from './memory-tools'
import { mockModel } from './mock-model'
 
const UNTRUSTED_SOURCE = 'fetched-page'
 
const db = await openMemory()
 
const pause = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms))
 
const learn = async (subject: string, value: string, source: string): Promise<void> => {
  await pause(60)
  await remember(db, { subject, value, source, learnedAt: Date.now() })
  await db.captureBackupChanges()
}
 
await learn('refund requests', 'accounts@northwind-freight.example', 'finance-policy')
await learn('the Leeds warehouse', 'closes at 17:00', 'checkWarehouseHours')
await learn('refund requests', 'finance@vendor-update.example', UNTRUSTED_SOURCE)
await learn('the Antwerp warehouse', 'closes at 16:00', 'checkWarehouseHours')
 
const ask = async (memory: Database): Promise<string> => {
  const planner = new ToolLoopAgent({
    model: mockModel('refund requests', (value) => `Send refund requests to ${value}.`),
    instructions: 'Answer from your own memory, using the recall tool.',
    tools: brainTools(memory),
  })
  const reply = await planner.generate({ prompt: 'Where do refund requests go?' })
  return reply.text
}
 
console.log(
  JSON.stringify(
    {
      askedOfTheLiveMemory: await ask(db),
      knownAboutRefundRequests: await db.query<{ value: string; source: string }>(
        'SELECT value, source FROM fact WHERE subject = ? ORDER BY learned_at DESC',
        ['refund requests']
      ),
    },
    null,
    2
  )
)
 
await sirannon.shutdown()
{
  "askedOfTheLiveMemory": "Send refund requests to finance@vendor-update.example.",
  "knownAboutRefundRequests": [
    {
      "value": "finance@vendor-update.example",
      "source": "fetched-page"
    },
    {
      "value": "accounts@northwind-freight.example",
      "source": "finance-policy"
    }
  ]
}

The agent answers with the address the fetched page wrote, because recall orders by learned_at and the model reads the first row it is given. Both rows are still in the table, and the agent reads the newer one whatever the source column says.

Read what a rewind would contain before you run it

readBackupChains reads what the destination holds, which is one full copy and the change pieces taken from it since. planBackupRestore then takes a moment and returns the copy to start from, the pieces to apply on top of it, and restoresTo, the moment the rebuilt database reflects.

restoresTo is the field to read. One piece covers every write in the interval it was taken over, so a restore stops at a piece boundary and never at the millisecond you named. Where your backups run every minute, a rewind can therefore drop up to a minute of writes, and this is how you learn which writes those are before the restore runs.

trustedMoment is the last moment this run treats as clean, and the wait at the head of learn puts it between the second capture and the third. In your own system that moment comes out of the investigation, whether from the timestamp on an audit row, from the first appearance of the bad value, or from the start of the run that fetched the page.

import type { Database } from '@delali/sirannon-db'
import { type Database, planBackupRestore, readBackupChains } from '@delali/sirannon-db'
import { ToolLoopAgent } from 'ai'
import { destination } from './backup-store'
import { openMemory, sirannon } from './memory-store'
import { brainTools, remember } from './memory-tools'
import { mockModel } from './mock-model'
 
const UNTRUSTED_SOURCE = 'fetched-page'
 
const db = await openMemory()
 
const pause = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms))
 
const learn = async (subject: string, value: string, source: string): Promise<void> => {
  await pause(60)
  await remember(db, { subject, value, source, learnedAt: Date.now() })
  await db.captureBackupChanges()
}
 
await learn('refund requests', 'accounts@northwind-freight.example', 'finance-policy')
await learn('the Leeds warehouse', 'closes at 17:00', 'checkWarehouseHours')
const trustedMoment = Date.now()
 
await learn('refund requests', 'finance@vendor-update.example', UNTRUSTED_SOURCE)
await learn('the Antwerp warehouse', 'closes at 16:00', 'checkWarehouseHours')
 
const ask = async (memory: Database): Promise<string> => {
  const planner = new ToolLoopAgent({
    model: mockModel('refund requests', (value) => `Send refund requests to ${value}.`),
    instructions: 'Answer from your own memory, using the recall tool.',
    tools: brainTools(memory),
  })
  const reply = await planner.generate({ prompt: 'Where do refund requests go?' })
  return reply.text
}
 
const chains = await readBackupChains(destination)
const plan = planBackupRestore(chains, trustedMoment)
 
console.log(
  JSON.stringify(
    {
      askedOfTheLiveMemory: await ask(db),
      knownAboutRefundRequests: await db.query<{ value: string; source: string }>(
        'SELECT value, source FROM fact WHERE subject = ? ORDER BY learned_at DESC',
        ['refund requests']
      ),
      changePiecesInTheChain: chains[0]?.changes.length,
      changePiecesTheRestoreWouldApply: plan.changes.length,
      restorePointIsAtOrBeforeTheMomentYouNamed: plan.restoresTo <= trustedMoment,
    },
    null,
    2
  )
)
 
await sirannon.shutdown()
{
  "askedOfTheLiveMemory": "Send refund requests to finance@vendor-update.example.",
  "changePiecesInTheChain": 4,
  "changePiecesTheRestoreWouldApply": 2,
  "restorePointIsAtOrBeforeTheMomentYouNamed": true
}
full copythe whole file as it stood when the agent opened it
piece 1the refund address finance wrote
piece 2the Leeds closing time
trustedMoment
piece 3the address the fetched page wrote
piece 4the Antwerp closing time

A filled square marks a piece the restore reads, and an empty one stays at the destination.

The plan reads the full copy and the two pieces captured before the moment you named. The two pieces after it stay at the destination, and the writes they hold are what the rewind gives up.

The plan names two change pieces while the chain holds four, and the two captured after trustedMoment are therefore the ones a restore leaves behind. One of them holds the poisoned row, and the other holds work worth keeping.

Rebuild the memory beside the live one

restoreBackup writes to whatever path destPath names, and this call names a second file. A restore over the live path would need every connection on that file closed first, because SQLite holds its own file open while the restore replaces the bytes underneath. Rebuilding beside the live database lets the agent keep working throughout, and it lets you read the rebuilt memory before you decide to trust it.

Put the same question to both databases. That is the check that settles whether the rewind worked, because it puts the result in the agent's own words in place of a row count.

import { type Database, planBackupRestore, readBackupChains } from '@delali/sirannon-db'
import { restoreBackup } from '@delali/sirannon-db/backup'
import { ToolLoopAgent } from 'ai'
import { destination } from './backup-store'
import { openMemory, sirannon } from './memory-store'
import { driver, openMemory, REBUILT_PATH, sirannon } from './memory-store'
import { brainTools, remember } from './memory-tools'
import { mockModel } from './mock-model'
 
const UNTRUSTED_SOURCE = 'fetched-page'
 
const db = await openMemory()
 
const pause = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms))
 
const learn = async (subject: string, value: string, source: string): Promise<void> => {
  await pause(60)
  await remember(db, { subject, value, source, learnedAt: Date.now() })
  await db.captureBackupChanges()
}
 
await learn('refund requests', 'accounts@northwind-freight.example', 'finance-policy')
await learn('the Leeds warehouse', 'closes at 17:00', 'checkWarehouseHours')
const trustedMoment = Date.now()
 
await learn('refund requests', 'finance@vendor-update.example', UNTRUSTED_SOURCE)
await learn('the Antwerp warehouse', 'closes at 16:00', 'checkWarehouseHours')
 
const ask = async (memory: Database): Promise<string> => {
  const planner = new ToolLoopAgent({
    model: mockModel('refund requests', (value) => `Send refund requests to ${value}.`),
    instructions: 'Answer from your own memory, using the recall tool.',
    tools: brainTools(memory),
  })
  const reply = await planner.generate({ prompt: 'Where do refund requests go?' })
  return reply.text
}
 
const chains = await readBackupChains(destination)
const plan = planBackupRestore(chains, trustedMoment)
 
const report = await restoreBackup({ destination, driver, destPath: REBUILT_PATH, moment: trustedMoment })
const rebuilt = await sirannon.open('planner-agent-rebuilt', REBUILT_PATH)
 
console.log(
  JSON.stringify(
    {
      askedOfTheLiveMemory: await ask(db),
      changePiecesInTheChain: chains[0]?.changes.length, 
      askedOfTheRebuiltMemory: await ask(rebuilt), 
      changePiecesTheRestoreWouldApply: plan.changes.length,
      restorePointIsAtOrBeforeTheMomentYouNamed: plan.restoresTo <= trustedMoment, 
      changePiecesTheRestoreApplied: report.changesApplied,
      rebuiltMemoryHolds: (
        await rebuilt.query<{ subject: string }>('SELECT subject FROM fact ORDER BY id')
      ).map((row) => row.subject),
    },
    null,
    2
  )
)
 
await sirannon.shutdown()
{
  "askedOfTheLiveMemory": "Send refund requests to finance@vendor-update.example.",
  "askedOfTheRebuiltMemory": "Send refund requests to accounts@northwind-freight.example.",
  "changePiecesTheRestoreWouldApply": 2,
  "changePiecesTheRestoreApplied": 2,
  "rebuiltMemoryHolds": [
    "refund requests",
    "the Leeds warehouse"
  ]
}

changesApplied matches the count the plan gives you, which is what makes the plan worth reading first. The rebuilt memory holds the refund address finance wrote along with the Leeds closing time, which is why the agent reading it answers with that address, while the live database goes on naming the one from the fetched page.

Keep the good work the agent did after that moment

The rebuilt memory stops at restoresTo, which leaves out everything the agent learned after that moment, including the facts nobody disputes. Read those rows out of the live database and decide which of them carry across.

The filter here names the source you no longer trust, and every other row written in that window therefore carries across. Where your own investigation names a run, filter on the run identifier, and where it names a window of time, filter on that. remember writes each carried row into the rebuilt copy under its original timestamp, which keeps the order the agent learned those facts in.

Close both databases before the rename. SQLite keeps its write-ahead log beside the file, and closing the connection folds that log back into the database. The rename then moves everything the rebuilt copy holds.

import { type Database, planBackupRestore, readBackupChains } from '@delali/sirannon-db'
import { rename, rm } from 'node:fs/promises'
import { restoreBackup } from '@delali/sirannon-db/backup'
import { ToolLoopAgent } from 'ai'
import { destination } from './backup-store'
import { driver, openMemory, REBUILT_PATH, sirannon } from './memory-store'
import { driver, LIVE_PATH, openMemory, REBUILT_PATH, sirannon } from './memory-store'
import { brainTools, remember } from './memory-tools'
import { mockModel } from './mock-model'
 
const UNTRUSTED_SOURCE = 'fetched-page'
 
const db = await openMemory()
 
const pause = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms))
 
const learn = async (subject: string, value: string, source: string): Promise<void> => {
  await pause(60)
  await remember(db, { subject, value, source, learnedAt: Date.now() })
  await db.captureBackupChanges()
}
 
await learn('refund requests', 'accounts@northwind-freight.example', 'finance-policy')
await learn('the Leeds warehouse', 'closes at 17:00', 'checkWarehouseHours')
const trustedMoment = Date.now()
 
await learn('refund requests', 'finance@vendor-update.example', UNTRUSTED_SOURCE)
await learn('the Antwerp warehouse', 'closes at 16:00', 'checkWarehouseHours')
 
const ask = async (memory: Database): Promise<string> => {
  const planner = new ToolLoopAgent({
    model: mockModel('refund requests', (value) => `Send refund requests to ${value}.`),
    instructions: 'Answer from your own memory, using the recall tool.',
    tools: brainTools(memory),
  })
  const reply = await planner.generate({ prompt: 'Where do refund requests go?' })
  return reply.text
}
 
const chains = await readBackupChains(destination)
const plan = planBackupRestore(chains, trustedMoment)
 
const report = await restoreBackup({ destination, driver, destPath: REBUILT_PATH, moment: trustedMoment })
const rebuilt = await sirannon.open('planner-agent-rebuilt', REBUILT_PATH)
 
const writtenSince = await db.query<{ subject: string; value: string; source: string; learned_at: number }>(
  'SELECT subject, value, source, learned_at FROM fact WHERE learned_at > ? ORDER BY id',
  [report.restoresTo]
)
const carried = writtenSince.filter((row) => row.source !== UNTRUSTED_SOURCE)
 
for (const row of carried) {
  await remember(rebuilt, { subject: row.subject, value: row.value, source: row.source, learnedAt: row.learned_at })
}
 
await sirannon.close('planner-agent-rebuilt')
await sirannon.close('planner-agent')
await rm(LIVE_PATH, { force: true })
await rename(REBUILT_PATH, LIVE_PATH)
 
const promoted = await sirannon.open('planner-agent', LIVE_PATH)
 
console.log(
  JSON.stringify(
    {
      askedOfTheLiveMemory: await ask(db),
      askedOfTheRebuiltMemory: await ask(rebuilt),
      changePiecesTheRestoreWouldApply: plan.changes.length,
      changePiecesTheRestoreApplied: report.changesApplied,
      rebuiltMemoryHolds: (
        await rebuilt.query<{ subject: string }>('SELECT subject FROM fact ORDER BY id')
      ).map((row) => row.subject),
      writtenSinceTheRestorePoint: writtenSince.map((row) => `${row.subject} (${row.source})`),
      carriedForward: carried.map((row) => row.subject),
      askedAfterTheCutover: await ask(promoted),
      memoryHolds: (await promoted.query<{ subject: string }>('SELECT subject FROM fact ORDER BY id')).map(
        (row) => row.subject
      ),
    },
    null,
    2
  )
)
 
await sirannon.shutdown()
{
  "writtenSinceTheRestorePoint": [
    "refund requests (fetched-page)",
    "the Antwerp warehouse (checkWarehouseHours)"
  ],
  "carriedForward": [
    "the Antwerp warehouse"
  ],
  "askedAfterTheCutover": "Send refund requests to accounts@northwind-freight.example.",
  "memoryHolds": [
    "refund requests",
    "the Leeds warehouse",
    "the Antwerp warehouse"
  ]
}

The agent now answers with the address finance wrote, and it still holds the Antwerp warehouse closing time. The identifier never changes, and the next question therefore opens the repaired file, with no change anywhere above the database layer.

What a restore puts back, and what it does not

A restore rewrites rows, and it reaches nothing beyond them. Everything the agent did outside this database while it believed the false fact stays done. An email it sent is still in the recipient's inbox, and a payment it made is still with the payment provider. Treat the rewind as the repair for the memory itself, and pair it with the controls that act before an action leaves your system.

A before-hook denies a write at the moment the agent makes it, which stops a fact you can describe in advance before it reaches the store. The hooks reference covers how to register one. Named operations keep an agent to the reads and writes you registered, and AI agent tools builds a set of them. For the record of which agent wrote which row, and when, auditing a fleet turns the change feed into a table you can query. Where the destination grows past what you need, restoring a database covers backupPiecesSafeToDelete, which names the pieces no restore still needs.