AI agents

Keep the index current

Write every change in your records to the index as an update or a removal, and wait for the worker copies, since the index keeps the last version that you wrote.

Take a customer who asks a food manufacturer's labelling AI agent whether the lemon drizzle loaf contains nuts, and who receives an answer from revision 3 of the declaration, which lists wheat, egg, and milk. Since a recipe change a week ago, the loaf contains almond flour, and revision 4 in the manufacturer's records contains the warning 'Not suitable for a nut allergy'. No part of the system reports an error. The record changes while the index still holds the version from the last write, so the AI agent answers from a declaration that is out of date.

The authors of a 2026 diagnostic study measure how much stale context affects an answer. When the models retrieve from an obsolete snapshot alone, their answers contain obsolete references on 15 of 17 samples with one model and 13 of 17 with the other, which the authors report as increases of 88.2 and 76.5 percentage points over retrieval from the current state (arXiv 2605.14478). Their corpus is source code, and the same mechanism applies here, because a search index can return only what it holds.

The model in the files below is a scripted mock that builds each answer from the words of Narsil's hits. Replace it with a real model when you want the phrasing to vary. Each code block below holds one whole file.

An index behind its own record

onFile holds the manufacturer's record of every declaration, and insertBatch copies it into the index. The code then writes the recipe change, revision 4, to the record alone. That is the usual form of this bug, in which your code writes a change to one of two stores.

import { createNarsil } from '@delali/narsil'
import { ToolLoopAgent, tool } from 'ai'
import { z } from 'zod'
import { type DeclarationHit, labellingModel, LOAF_QUESTION, LOAF_SEARCH } from './mock-model.ts'
 
type Declaration = {
  id: string
  product: string
  declaration: string
  revision: number
}
 
const onFile = new Map<string, Declaration>([
  [
    'lemon-drizzle-loaf',
    {
      id: 'lemon-drizzle-loaf',
      product: 'Lemon drizzle loaf',
      declaration: 'Contains wheat, egg, and milk. Made on a line that also handles sesame.',
      revision: 3,
    },
  ],
  [
    'cherry-bakewell-slice',
    {
      id: 'cherry-bakewell-slice',
      product: 'Cherry bakewell slice',
      declaration: 'Contains wheat, egg, milk, and almond. Not suitable for a nut allergy.',
      revision: 7,
    },
  ],
  [
    'oat-and-raisin-cookie',
    {
      id: 'oat-and-raisin-cookie',
      product: 'Oat and raisin cookie',
      declaration: 'Contains oats and wheat. Made on a line that also handles peanut.',
      revision: 2,
    },
  ],
])
 
const narsil = await createNarsil()
 
await narsil.createIndex('product_declarations', {
  schema: { product: 'string', declaration: 'string', revision: 'number' },
  language: 'english',
})
 
await narsil.insertBatch('product_declarations', [...onFile.values()])
 
onFile.set('lemon-drizzle-loaf', {
  id: 'lemon-drizzle-loaf',
  product: 'Lemon drizzle loaf',
  declaration: 'Contains wheat, egg, milk, and almond. Not suitable for a nut allergy.',
  revision: 4,
})
 
const labellingTools = {
  searchDeclarations: tool({
    description: 'Search the allergen declarations by what their text says.',
    inputSchema: z.object({ text: z.string() }),
    execute: async ({ text }: { text: string }): Promise<DeclarationHit[]> => {
      const found = await narsil.query<DeclarationHit>('product_declarations', {
        term: text,
        document: { include: ['product', 'declaration', 'revision'] },
        limit: 1,
      })
      return found.hits.map((hit) => hit.document)
    },
  }),
}
 
const askLabellingAgent = async (question: string, searchText: string): Promise<string> => {
  const agent = new ToolLoopAgent({
    model: labellingModel(searchText),
    instructions: 'Answer from the allergen declarations alone.',
    tools: labellingTools,
  })
  const reply = await agent.generate({ prompt: question })
  return reply.text
}
 
console.log(
  JSON.stringify(
    {
      question: LOAF_QUESTION,
      agentAnswered: await askLabellingAgent(LOAF_QUESTION, LOAF_SEARCH),
      onFileNow: onFile.get('lemon-drizzle-loaf')?.declaration,
    },
    null,
    2
  )
)
 
await narsil.shutdown()
{
  "question": "Does the lemon drizzle loaf contain nuts?",
  "agentAnswered": "Contains wheat, egg, and milk. Made on a line that also handles sesame. (Lemon drizzle loaf, revision 3)",
  "onFileNow": "Contains wheat, egg, milk, and almond. Not suitable for a nut allergy."
}

The panel holds both sides of the gap, which are the AI agent's answer and the record at the moment of that answer. People act on a labelling answer, and a reader can spot the problem only from the revision number in it.

onFileproduct_declarationssearched by the AI agent
  • Lemon drizzle loafrev 4rev 3
  • Cherry bakewell slicerev 7rev 7
  • Oat and raisin cookierev 2rev 2
Each store holds its own revision of every declaration, and the AI agent searches the index on the right, where the lemon drizzle loaf is still at revision 3.

Write the change to both stores at once

declare is now the only function that changes a declaration, and it writes to the record and to the index together. update replaces the document with that id and reindexes all of its fields, so from the next query onwards the engine ranks the new declaration by its new words.

import { createNarsil } from '@delali/narsil'
import { ToolLoopAgent, tool } from 'ai'
import { z } from 'zod'
import { type DeclarationHit, labellingModel, LOAF_QUESTION, LOAF_SEARCH } from './mock-model.ts'
 
type Declaration = {
  id: string
  product: string
  declaration: string
  revision: number
}
 
const onFile = new Map<string, Declaration>([
  [
    'lemon-drizzle-loaf',
    {
      id: 'lemon-drizzle-loaf',
      product: 'Lemon drizzle loaf',
      declaration: 'Contains wheat, egg, and milk. Made on a line that also handles sesame.',
      revision: 3,
    },
  ],
  [
    'cherry-bakewell-slice',
    {
      id: 'cherry-bakewell-slice',
      product: 'Cherry bakewell slice',
      declaration: 'Contains wheat, egg, milk, and almond. Not suitable for a nut allergy.',
      revision: 7,
    },
  ],
  [
    'oat-and-raisin-cookie',
    {
      id: 'oat-and-raisin-cookie',
      product: 'Oat and raisin cookie',
      declaration: 'Contains oats and wheat. Made on a line that also handles peanut.',
      revision: 2,
    },
  ],
])
 
const narsil = await createNarsil()
 
await narsil.createIndex('product_declarations', {
  schema: { product: 'string', declaration: 'string', revision: 'number' },
  language: 'english',
})
 
await narsil.insertBatch('product_declarations', [...onFile.values()])
 
onFile.set('lemon-drizzle-loaf', { 
const declare = async (declaration: Declaration): Promise<void> => { 
  onFile.set(declaration.id, declaration)
  await narsil.update('product_declarations', declaration.id, declaration)
}
 
await declare({
  id: 'lemon-drizzle-loaf',
  product: 'Lemon drizzle loaf',
  declaration: 'Contains wheat, egg, milk, and almond. Not suitable for a nut allergy.',
  revision: 4,
})
 
const labellingTools = {
  searchDeclarations: tool({
    description: 'Search the allergen declarations by what their text says.',
    inputSchema: z.object({ text: z.string() }),
    execute: async ({ text }: { text: string }): Promise<DeclarationHit[]> => {
      const found = await narsil.query<DeclarationHit>('product_declarations', {
        term: text,
        document: { include: ['product', 'declaration', 'revision'] },
        limit: 1,
      })
      return found.hits.map((hit) => hit.document)
    },
  }),
}
 
const askLabellingAgent = async (question: string, searchText: string): Promise<string> => {
  const agent = new ToolLoopAgent({
    model: labellingModel(searchText),
    instructions: 'Answer from the allergen declarations alone.',
    tools: labellingTools,
  })
  const reply = await agent.generate({ prompt: question })
  return reply.text
}
 
console.log(
  JSON.stringify(
    {
      question: LOAF_QUESTION,
      agentAnswered: await askLabellingAgent(LOAF_QUESTION, LOAF_SEARCH),
      onFileNow: onFile.get('lemon-drizzle-loaf')?.declaration,
    },
    null,
    2
  )
)
 
await narsil.shutdown()
{
  "question": "Does the lemon drizzle loaf contain nuts?",
  "agentAnswered": "Contains wheat, egg, milk, and almond. Not suitable for a nut allergy. (Lemon drizzle loaf, revision 4)",
  "onFileNow": "Contains wheat, egg, milk, and almond. Not suitable for a nut allergy."
}

The AI agent now answers with revision 4 and the allergy warning. update throws DOC_NOT_FOUND when the index has no document with that id, and you can rely on that error to catch any case where your records and the index disagree. To branch on the code with isNarsilError, see Error handling.

Withdraw a product, and wait for the copies

When the manufacturer withdraws a product line, you remove its document. remove deletes the document from the index, after which a query returns nothing for that product, and that is the true answer once the manufacturer stops making it.

waitForWrites(indexName) resolves once every worker copy of the index has applied every write that returned before the call. For an index with no copies the promise resolves at once, which means the call adds no delay on an embedded engine, and the same code is correct when you add worker copies. Pass { wait: true } to a single write when you want the same guarantee for that write alone, and call waitForWrites after a series of writes. Partitions and workers has the details of the worker copies and of how the engine routes queries to them.

import { createNarsil } from '@delali/narsil'
import { ToolLoopAgent, tool } from 'ai'
import { z } from 'zod'
import { type DeclarationHit, labellingModel, LOAF_QUESTION, LOAF_SEARCH } from './mock-model.ts'
import { 
  COOKIE_QUESTION,
  COOKIE_SEARCH,
  type DeclarationHit,
  labellingModel,
  LOAF_QUESTION,
  LOAF_SEARCH,
} from './mock-model.ts'
 
type Declaration = {
  id: string
  product: string
  declaration: string
  revision: number
}
 
const onFile = new Map<string, Declaration>([
  [
    'lemon-drizzle-loaf',
    {
      id: 'lemon-drizzle-loaf',
      product: 'Lemon drizzle loaf',
      declaration: 'Contains wheat, egg, and milk. Made on a line that also handles sesame.',
      revision: 3,
    },
  ],
  [
    'cherry-bakewell-slice',
    {
      id: 'cherry-bakewell-slice',
      product: 'Cherry bakewell slice',
      declaration: 'Contains wheat, egg, milk, and almond. Not suitable for a nut allergy.',
      revision: 7,
    },
  ],
  [
    'oat-and-raisin-cookie',
    {
      id: 'oat-and-raisin-cookie',
      product: 'Oat and raisin cookie',
      declaration: 'Contains oats and wheat. Made on a line that also handles peanut.',
      revision: 2,
    },
  ],
])
 
const narsil = await createNarsil()
 
await narsil.createIndex('product_declarations', {
  schema: { product: 'string', declaration: 'string', revision: 'number' },
  language: 'english',
})
 
await narsil.insertBatch('product_declarations', [...onFile.values()])
 
const declare = async (declaration: Declaration): Promise<void> => {
  onFile.set(declaration.id, declaration)
  await narsil.update('product_declarations', declaration.id, declaration)
}
 
const withdraw = async (id: string): Promise<void> => { 
  onFile.delete(id)
  await narsil.remove('product_declarations', id)
}
 
await declare({
  id: 'lemon-drizzle-loaf',
  product: 'Lemon drizzle loaf',
  declaration: 'Contains wheat, egg, milk, and almond. Not suitable for a nut allergy.',
  revision: 4,
})
 
await withdraw('oat-and-raisin-cookie') 
 
await narsil.waitForWrites('product_declarations')
 
const labellingTools = {
  searchDeclarations: tool({
    description: 'Search the allergen declarations by what their text says.',
    inputSchema: z.object({ text: z.string() }),
    execute: async ({ text }: { text: string }): Promise<DeclarationHit[]> => {
      const found = await narsil.query<DeclarationHit>('product_declarations', {
        term: text,
        document: { include: ['product', 'declaration', 'revision'] },
        limit: 1,
      })
      return found.hits.map((hit) => hit.document)
    },
  }),
}
 
const askLabellingAgent = async (question: string, searchText: string): Promise<string> => {
  const agent = new ToolLoopAgent({
    model: labellingModel(searchText),
    instructions: 'Answer from the allergen declarations alone.',
    tools: labellingTools,
  })
  const reply = await agent.generate({ prompt: question })
  return reply.text
}
 
console.log(
  JSON.stringify(
    { 
      question: LOAF_QUESTION, 
      agentAnswered: await askLabellingAgent(LOAF_QUESTION, LOAF_SEARCH), 
      onFileNow: onFile.get('lemon-drizzle-loaf')?.declaration, 
    }, 
    [ 
      { question: LOAF_QUESTION, agentAnswered: await askLabellingAgent(LOAF_QUESTION, LOAF_SEARCH) },
      { question: COOKIE_QUESTION, agentAnswered: await askLabellingAgent(COOKIE_QUESTION, COOKIE_SEARCH) },
    ],
    null,
    2
  )
)
 
await narsil.shutdown()
[
  {
    "question": "Does the lemon drizzle loaf contain nuts?",
    "agentAnswered": "Contains wheat, egg, milk, and almond. Not suitable for a nut allergy. (Lemon drizzle loaf, revision 4)"
  },
  {
    "question": "Does the oat and raisin cookie contain nuts?",
    "agentAnswered": "No declaration is on file for that product."
  }
]

A search for the withdrawn cookie returns nothing, and the AI agent tells the customer so, which is why you remove the document. If you marked the cookie as withdrawn inside its own text, the words that rank it would stay in the index, and the model would still receive the allergen line alongside the marking.

When a database holds the records in place of a map, connect the same pair of writes to that database's change feed, with one subscriber that applies each committed change to the index. If the process stops between the commit and the index write, the index still holds the old version. To repair it, read the records from the source and write them again with insertBatch.

Where to go next

  • Read Indexes and documents for insert, update, remove, the batch calls, and the errors that each of them throws.
  • See Partitions and workers for the worker copies that waitForWrites waits on, and for how the engine routes queries to them.
  • Keep the hit ids behind every answer in the journal from Record every search, so that a reader can find the revision behind each answer.