> Complete content index: https://narsil.sondelali.com/llms.txt

# Fit the passages in a context budget

> Measure the characters that a search tool returns, set each passage's cap from the text that it has to include, and keep the total within a budget.

URL: https://narsil.sondelali.com/docs/agent-context-budget
Section: AI agents
Version: 0.3 (@delali/narsil@0.3.0, latest)

Picture a technician up a wind turbine who asks the maintenance AI agent what torque to use on the blade root bolts, and who hears 2400 newton metres. That figure is for the tower flange bolts. The blade root section contains the right figure, 1100 newton metres, at character 361 of a 704-character passage; however, the snippet that the tool returns ends at character 243.

The cause is a context budget. The authors of a 2026 paper on long-horizon search agents describe how memory, latency, and cost limit the size of an AI agent's context in deployment, which leaves the agent to choose between keeping the passages from earlier searches and staying under that limit ([arXiv 2604.01664](https://arxiv.org/abs/2604.01664)). Cutting the passages is the obvious saving; however, when you cut them to a length that somebody guesses, a technician can receive the wrong torque.

Each figure below is the output of the file above it. The model is a mock that scans the passages in its input and returns the first torque figure that matches, which means that when the second step prints a wrong answer, the cause is the tool's output. Each code block below holds one whole file.

## Measure what the tool sends back

`reply.steps` holds one entry for each turn of the loop, and each `toolResults` entry holds the value that the tool returns. Serialising those values gives the number of characters that the loop adds to the next prompt, and that is the figure to measure before you cut anything.

Two of the four manual sections match the query, and the tool returns both as whole stored bodies.

<CodeGroup defaultTab="service.ts">

```ts title="service.ts"
import { createNarsil } from '@delali/narsil'
import { ToolLoopAgent, tool } from 'ai'
import { z } from 'zod'
import { type ManualPassage, QUESTION, technicianModel } from './mock-model.ts'

const narsil = await createNarsil()

await narsil.createIndex('turbine_manual', {
  schema: { turbine: 'enum', section: 'string', body: 'string' },
  language: 'english',
})

await narsil.insertBatch('turbine_manual', [
  {
    id: 'v90-blade-root',
    turbine: 'v90-2mw',
    section: 'Blade root bolt inspection',
    body: 'Inspect the blade root bolts at every 12-month service and after any lightning strike on the blade. Sound each bolt with a test hammer before you apply a wrench, because a bolt that rings dull has already lost its preload and takes replacement rather than retightening. Tighten each bolt in the crossing sequence printed on the hub casting, in three passes, to 1100 newton metres at the final pass. Record the final reading for every bolt against its position number, and stamp the hub log with the date and your technician number. A bolt that fails to hold the final figure across two consecutive passes goes on the replacement list, and the turbine stays out of service until the whole set is replaced.',
  },
  {
    id: 'v90-yaw-brake',
    turbine: 'v90-2mw',
    section: 'Yaw brake calipers',
    body: 'Measure the pad thickness on all six yaw brake calipers at every 12-month service, and replace the full set once any pad falls below 8 millimetres. Check the hydraulic pressure at the manifold against the nameplate figure before you release the brake, since a caliper that drags heats the yaw ring and scores the friction surface. Grease the yaw ring teeth through the four ports on the deck plate, and turn the nacelle a quarter circle between ports so that the grease reaches the whole ring. Log the pad measurements and the pressure reading in the maintenance record, and raise a repair order for any caliper that leaks at the piston seal.',
  },
  {
    id: 'v90-gearbox-oil',
    turbine: 'v90-2mw',
    section: 'Gearbox oil sampling',
    body: 'Draw the gearbox oil sample from the running loop rather than the sump, with the turbine at a steady output above 500 kilowatts, so that the sample carries the particles that circulate in service. Fill the bottle to the shoulder, cap it at once, and label it with the turbine number, the date, and the running hours. Send the sample to the laboratory within five working days, because the iron count reads high once water has had time to settle out. Replace the breather filter at every sample, and check the sight glass for the milky tint that tells you water has entered the housing.',
  },
  {
    id: 'v90-tower-bolts',
    turbine: 'v90-2mw',
    section: 'Tower flange bolts',
    body: 'Check the tower flange bolts at the first 3-month service after commissioning and at every 24 months afterwards. Retighten to 2400 newton metres at the lower flange and 1800 newton metres at the upper flange, in the sequence on the flange drawing. Replace any bolt whose shank shows a witness mark of movement against the flange face, together with its nut and both washers. Record the readings against the flange drawing positions, and report any flange gap above 0.5 millimetres to the site engineer before you leave the tower.',
  },
])

const technicianTools = {
  searchManual: tool({
    description: 'Search the turbine service manual by what its text says.',
    inputSchema: z.object({ text: z.string() }),
    execute: async ({ text }: { text: string }): Promise<ManualPassage[]> => {
      const found = await narsil.query<{ section: string; body: string }>('turbine_manual', { term: text, limit: 4 })
      return found.hits.map((hit) => ({ section: hit.document.section, text: hit.document.body }))
    },
  }),
}

let charactersReturned = 0

const askTechnicianAgent = async (): Promise<string> => {
  const agent = new ToolLoopAgent({
    model: technicianModel(),
    instructions: 'Answer the technician from the turbine service manual.',
    tools: technicianTools,
  })
  const reply = await agent.generate({ prompt: QUESTION })
  for (const step of reply.steps) {
    for (const result of step.toolResults) charactersReturned += JSON.stringify(result.output).length
  }
  return reply.text
}

console.log(
  JSON.stringify({ question: QUESTION, agentAnswered: await askTechnicianAgent(), charactersReturned }, null, 2)
)

await narsil.shutdown()
```

```json result open
{
  "question": "What torque do the blade root bolts take?",
  "agentAnswered": "1100 newton metres, from 'Blade root bolt inspection'.",
  "charactersReturned": 1328
}
```

```ts title="mock-model.ts"
import { MockLanguageModelV4 } from 'ai/test'

const NO_USAGE = {
  inputTokens: { total: 0, noCache: 0, cacheRead: undefined, cacheWrite: undefined },
  outputTokens: { total: 0, text: 0, reasoning: undefined },
}

export const QUESTION = 'What torque do the blade root bolts take?'

export interface ManualPassage {
  section: string
  text: string
}

interface ToolResultPart {
  toolName?: string
  output?: { value?: unknown }
}

interface PromptMessage {
  role: string
  content: ToolResultPart[] | string
}

const everythingReturnedBy = (prompt: unknown, toolName: string): unknown[] => {
  const outputs: unknown[] = []
  for (const message of prompt as PromptMessage[]) {
    if (message.role !== 'tool' || typeof message.content === 'string') continue
    for (const part of message.content) {
      if (part.toolName === toolName) outputs.push(part.output?.value)
    }
  }
  return outputs
}

const callTool = (toolName: string, input: Record<string, unknown>) => ({
  content: [{ type: 'tool-call' as const, toolCallId: `${toolName}-1`, toolName, input: JSON.stringify(input) }],
  finishReason: { unified: 'tool-calls' as const, raw: undefined },
  usage: NO_USAGE,
  warnings: [],
})

const say = (text: string) => ({
  content: [{ type: 'text' as const, text }],
  finishReason: { unified: 'stop' as const, raw: undefined },
  usage: NO_USAGE,
  warnings: [],
})

const TORQUE = /(\d+)\s*newton metres/

export const technicianModel = () =>
  new MockLanguageModelV4({
    doGenerate: async ({ prompt }) => {
      const searches = everythingReturnedBy(prompt, 'searchManual') as ManualPassage[][]
      if (searches.length === 0) return callTool('searchManual', { text: 'blade root bolt torque' })
      const found = searches[0]
      for (const passage of found) {
        const match = passage.text.match(TORQUE)
        if (match !== null) return say(`${match[1]} newton metres, from '${passage.section}'.`)
      }
      return say('No passage here gives a torque figure.')
    },
  })
```

</CodeGroup>

The answer is right, and the tool output for this one question totals 1,328 characters. Those characters include the lightning-strike rule, the hammer test, the hub log, and the replacement list, and the technician's question is about none of them. Narsil can give you a count of characters, whereas to count tokens you need the model's own tokeniser.

## Cap each passage, and watch where the cap falls

With `highlight` set, the engine cuts each field to `maxSnippetLength` characters around the densest run of matches, and with `document.include` it returns only the section title from each document. With both options, the tool output falls from 1,328 characters to 581.

<CodeGroup defaultTab="service.ts">

```ts title="service.ts"
import { createNarsil } from '@delali/narsil'
import { ToolLoopAgent, tool } from 'ai'
import { z } from 'zod'
import { type ManualPassage, QUESTION, technicianModel } from './mock-model.ts'

const narsil = await createNarsil()

await narsil.createIndex('turbine_manual', {
  schema: { turbine: 'enum', section: 'string', body: 'string' },
  language: 'english',
})

await narsil.insertBatch('turbine_manual', [
  {
    id: 'v90-blade-root',
    turbine: 'v90-2mw',
    section: 'Blade root bolt inspection',
    body: 'Inspect the blade root bolts at every 12-month service and after any lightning strike on the blade. Sound each bolt with a test hammer before you apply a wrench, because a bolt that rings dull has already lost its preload and takes replacement rather than retightening. Tighten each bolt in the crossing sequence printed on the hub casting, in three passes, to 1100 newton metres at the final pass. Record the final reading for every bolt against its position number, and stamp the hub log with the date and your technician number. A bolt that fails to hold the final figure across two consecutive passes goes on the replacement list, and the turbine stays out of service until the whole set is replaced.',
  },
  {
    id: 'v90-yaw-brake',
    turbine: 'v90-2mw',
    section: 'Yaw brake calipers',
    body: 'Measure the pad thickness on all six yaw brake calipers at every 12-month service, and replace the full set once any pad falls below 8 millimetres. Check the hydraulic pressure at the manifold against the nameplate figure before you release the brake, since a caliper that drags heats the yaw ring and scores the friction surface. Grease the yaw ring teeth through the four ports on the deck plate, and turn the nacelle a quarter circle between ports so that the grease reaches the whole ring. Log the pad measurements and the pressure reading in the maintenance record, and raise a repair order for any caliper that leaks at the piston seal.',
  },
  {
    id: 'v90-gearbox-oil',
    turbine: 'v90-2mw',
    section: 'Gearbox oil sampling',
    body: 'Draw the gearbox oil sample from the running loop rather than the sump, with the turbine at a steady output above 500 kilowatts, so that the sample carries the particles that circulate in service. Fill the bottle to the shoulder, cap it at once, and label it with the turbine number, the date, and the running hours. Send the sample to the laboratory within five working days, because the iron count reads high once water has had time to settle out. Replace the breather filter at every sample, and check the sight glass for the milky tint that tells you water has entered the housing.',
  },
  {
    id: 'v90-tower-bolts',
    turbine: 'v90-2mw',
    section: 'Tower flange bolts',
    body: 'Check the tower flange bolts at the first 3-month service after commissioning and at every 24 months afterwards. Retighten to 2400 newton metres at the lower flange and 1800 newton metres at the upper flange, in the sequence on the flange drawing. Replace any bolt whose shank shows a witness mark of movement against the flange face, together with its nut and both washers. Record the readings against the flange drawing positions, and report any flange gap above 0.5 millimetres to the site engineer before you leave the tower.',
  },
])

const technicianTools = {
  searchManual: tool({
    description: 'Search the turbine service manual by what its text says.',
    inputSchema: z.object({ text: z.string() }),
    execute: async ({ text }: { text: string }): Promise<ManualPassage[]> => {
      const found = await narsil.query<{ section: string; body: string }>('turbine_manual', { term: text, limit: 4 }) // [!code --]
      return found.hits.map((hit) => ({ section: hit.document.section, text: hit.document.body })) // [!code --]
      const found = await narsil.query<{ section: string }>('turbine_manual', { // [!code ++:10]
        term: text,
        highlight: { fields: ['body'], preTag: '', postTag: '', maxSnippetLength: 240 },
        document: { include: ['section'] },
        limit: 4,
      })
      return found.hits.map((hit) => ({
        section: hit.document.section,
        text: hit.highlights?.body?.snippet ?? '',
      }))
    },
  }),
}

let charactersReturned = 0

const askTechnicianAgent = async (): Promise<string> => {
  const agent = new ToolLoopAgent({
    model: technicianModel(),
    instructions: 'Answer the technician from the turbine service manual.',
    tools: technicianTools,
  })
  const reply = await agent.generate({ prompt: QUESTION })
  for (const step of reply.steps) {
    for (const result of step.toolResults) charactersReturned += JSON.stringify(result.output).length
  }
  return reply.text
}

console.log(
  JSON.stringify({ question: QUESTION, agentAnswered: await askTechnicianAgent(), charactersReturned }, null, 2)
)

await narsil.shutdown()
```

```json result open
{
  "question": "What torque do the blade root bolts take?",
  "agentAnswered": "2400 newton metres, from 'Tower flange bolts'.",
  "charactersReturned": 581
}
```

</CodeGroup>

The tool now returns 56% fewer characters, and the answer is wrong. The query matches six words in the blade root section, and all six are within its first 176 characters. The engine therefore centres the snippet on the opening and ends it at character 243, which is 118 characters before the torque figure at character 361. The tower flange snippet does contain a figure, 2400 newton metres for a different set of bolts, and the model returns the first figure in its input.

<SnippetCapDiagram
  field="turbine_manual / v90-blade-root / body"
  fieldCharacters={704}
  matchesEnd={176}
  figureStart={361}
  figureLabel="1100 newton metres"
  caps={[
    { characters: 240, reachesTheFigure: false },
    { characters: 320, reachesTheFigure: false },
    { characters: 400, reachesTheFigure: true },
  ]}
  caption="The shaded band holds the six matched words, and each dashed line marks where a snippet of that length ends. Only the 400-character snippet extends past the torque figure at character 361."
/>

## Choose the cap from the passage, and the count from the budget

Read the field before you choose the cap. A snippet of the blade root section has to be 400 characters long to include its figure, as you can see in the diagram above, and at 400 characters two passages come to 901 characters of tool output.

`RETRIEVAL_BUDGET_CHARACTERS` is the retrieval allowance for one question in this application. `withinBudget` serialises the passages that it is about to return and keeps adding them while the total is within that allowance, which means the number of passages follows from the budget, with no arithmetic to redo when a section grows. Here two passages total 901 characters, about 450 each, and a third would push the total past the 1,200 allowance, which is why `withinBudget` leaves it out.

<CodeGroup defaultTab="service.ts">

```ts title="service.ts"
import { createNarsil } from '@delali/narsil'
import { ToolLoopAgent, tool } from 'ai'
import { z } from 'zod'
import { type ManualPassage, QUESTION, technicianModel } from './mock-model.ts'

const narsil = await createNarsil()

await narsil.createIndex('turbine_manual', {
  schema: { turbine: 'enum', section: 'string', body: 'string' },
  language: 'english',
})

await narsil.insertBatch('turbine_manual', [
  {
    id: 'v90-blade-root',
    turbine: 'v90-2mw',
    section: 'Blade root bolt inspection',
    body: 'Inspect the blade root bolts at every 12-month service and after any lightning strike on the blade. Sound each bolt with a test hammer before you apply a wrench, because a bolt that rings dull has already lost its preload and takes replacement rather than retightening. Tighten each bolt in the crossing sequence printed on the hub casting, in three passes, to 1100 newton metres at the final pass. Record the final reading for every bolt against its position number, and stamp the hub log with the date and your technician number. A bolt that fails to hold the final figure across two consecutive passes goes on the replacement list, and the turbine stays out of service until the whole set is replaced.',
  },
  {
    id: 'v90-yaw-brake',
    turbine: 'v90-2mw',
    section: 'Yaw brake calipers',
    body: 'Measure the pad thickness on all six yaw brake calipers at every 12-month service, and replace the full set once any pad falls below 8 millimetres. Check the hydraulic pressure at the manifold against the nameplate figure before you release the brake, since a caliper that drags heats the yaw ring and scores the friction surface. Grease the yaw ring teeth through the four ports on the deck plate, and turn the nacelle a quarter circle between ports so that the grease reaches the whole ring. Log the pad measurements and the pressure reading in the maintenance record, and raise a repair order for any caliper that leaks at the piston seal.',
  },
  {
    id: 'v90-gearbox-oil',
    turbine: 'v90-2mw',
    section: 'Gearbox oil sampling',
    body: 'Draw the gearbox oil sample from the running loop rather than the sump, with the turbine at a steady output above 500 kilowatts, so that the sample carries the particles that circulate in service. Fill the bottle to the shoulder, cap it at once, and label it with the turbine number, the date, and the running hours. Send the sample to the laboratory within five working days, because the iron count reads high once water has had time to settle out. Replace the breather filter at every sample, and check the sight glass for the milky tint that tells you water has entered the housing.',
  },
  {
    id: 'v90-tower-bolts',
    turbine: 'v90-2mw',
    section: 'Tower flange bolts',
    body: 'Check the tower flange bolts at the first 3-month service after commissioning and at every 24 months afterwards. Retighten to 2400 newton metres at the lower flange and 1800 newton metres at the upper flange, in the sequence on the flange drawing. Replace any bolt whose shank shows a witness mark of movement against the flange face, together with its nut and both washers. Record the readings against the flange drawing positions, and report any flange gap above 0.5 millimetres to the site engineer before you leave the tower.',
  },
])

const RETRIEVAL_BUDGET_CHARACTERS = 1200 // [!code ++:12]
const SHORTEST_PASSAGE_HOLDING_A_FIGURE = 400

const withinBudget = (passages: ManualPassage[]): ManualPassage[] => {
  const kept: ManualPassage[] = []
  for (const passage of passages) {
    if (JSON.stringify([...kept, passage]).length > RETRIEVAL_BUDGET_CHARACTERS) break
    kept.push(passage)
  }
  return kept
}

const technicianTools = {
  searchManual: tool({
    description: 'Search the turbine service manual by what its text says.',
    inputSchema: z.object({ text: z.string() }),
    execute: async ({ text }: { text: string }): Promise<ManualPassage[]> => {
      const found = await narsil.query<{ section: string }>('turbine_manual', {
        term: text,
        highlight: { fields: ['body'], preTag: '', postTag: '', maxSnippetLength: 240 }, // [!code --]
        highlight: { // [!code ++:6]
          fields: ['body'],
          preTag: '',
          postTag: '',
          maxSnippetLength: SHORTEST_PASSAGE_HOLDING_A_FIGURE,
        },
        document: { include: ['section'] },
        limit: 4,
      })
      return found.hits.map((hit) => ({ // [!code --]
        section: hit.document.section, // [!code --]
        text: hit.highlights?.body?.snippet ?? '', // [!code --]
      })) // [!code --]
      return withinBudget( // [!code ++:3]
        found.hits.map((hit) => ({ section: hit.document.section, text: hit.highlights?.body?.snippet ?? '' }))
      )
    },
  }),
}

let charactersReturned = 0

const askTechnicianAgent = async (): Promise<string> => {
  const agent = new ToolLoopAgent({
    model: technicianModel(),
    instructions: 'Answer the technician from the turbine service manual.',
    tools: technicianTools,
  })
  const reply = await agent.generate({ prompt: QUESTION })
  for (const step of reply.steps) {
    for (const result of step.toolResults) charactersReturned += JSON.stringify(result.output).length
  }
  return reply.text
}

console.log(
  JSON.stringify({ question: QUESTION, agentAnswered: await askTechnicianAgent(), charactersReturned }, null, 2)
)

await narsil.shutdown()
```

```json result open
{
  "question": "What torque do the blade root bolts take?",
  "agentAnswered": "1100 newton metres, from 'Blade root bolt inspection'.",
  "charactersReturned": 901
}
```

</CodeGroup>

The technician hears 1100 newton metres from the blade root section, and the tool output is 901 characters, 32% below the whole-document figure of 1,328. That is a smaller saving than the 56% from the guessed cap; however, with this cap the answer is right.

When the budget has too little room for the passages that contain the answer, you have two options that keep the answer right. You can split the stored text into shorter sections before you index it, or you can set `document` to `false` and give the model a second tool that fetches the one passage that it chooses. [Indexes and documents](/docs/indexes-and-documents) has the details of splitting documents as you write them.

## Where to go next

- Read [Full-text search](/docs/full-text-search) for `maxSnippetLength`, the tags, and how the engine picks the run of matches to centre on.
- See [Sorting, grouping, and pagination](/docs/sorting-grouping-and-pagination) for `limit`, the cursor, and the grouping option that returns one passage from each section.
- Handle the case where every passage fits the budget and none of them contains the answer with [When the index has no good match](/docs/agent-weak-matches).
