AI agents

Give a model a search tool

Give a model a search tool that takes a resident's own words, since a lookup by key needs an identifier that the model has no way of knowing.

Say that a resident phones the council about an old fridge, and the AI agent on the line replies that it has no page on the subject, although two pages of the guidance are about fridges. The agent's one tool fetches a page by its slug. The model therefore has to make up a slug, and the slug that it makes up matches no page.

The author of a 2026 diagnostic benchmark sorts failed tool use into four kinds, which are a skipped call, an ignored result, a fabricated output, and an unnecessary call. Across nineteen models, the best has a clean tool-use rate of 86.33% (arXiv 2607.04686). Which of those four failures a model can make depends on the shape of its tool.

The model on this page is a scripted mock, so the code prints the same text every time it executes. Replace the mock with a model string when you want a real model to choose the calls. Each code block below holds one whole file.

A tool that takes only an identifier

readPage takes a slug and returns the page with that slug. That tool is useful when the conversation already contains a slug. The resident's question contains none, however, which means the model has to guess one, and narsil.get returns undefined for the guess.

import { createNarsil } from '@delali/narsil'
import { ToolLoopAgent, tool } from 'ai'
import { z } from 'zod'
import { helplineModel, QUESTION, type WastePage } from './mock-model.ts'
 
const narsil = await createNarsil()
 
await narsil.createIndex('waste_pages', {
  schema: { title: 'string', body: 'string', service: 'enum' },
  language: 'english',
})
 
await narsil.insertBatch('waste_pages', [
  {
    id: 'bulky-waste',
    title: 'Book a bulky waste collection',
    service: 'collections',
    body: 'A fridge, a freezer, a mattress, or a sofa goes on a bulky waste collection, which costs £22 for up to three items. The crew takes the items from the kerbside on the morning of the booking.',
  },
  {
    id: 'recycling-centre',
    title: 'Using the household recycling centre',
    service: 'sites',
    body: 'The household recycling centre on Marsh Lane takes a fridge, garden waste, and rubble free of charge from any resident holding a site permit.',
  },
  {
    id: 'missed-collection',
    title: 'Report a missed collection',
    service: 'collections',
    body: 'Report a missed bin within two working days and the crew returns to the street on the next working day.',
  },
  {
    id: 'food-waste',
    title: 'Food waste caddies',
    service: 'collections',
    body: 'Every household has a kitchen caddy and a kerbside caddy for food waste, emptied each week.',
  },
  {
    id: 'small-electricals',
    title: 'Small electrical items',
    service: 'sites',
    body: 'A kettle, a toaster, or a hairdryer goes in the small electricals bank at any of the four recycling banks.',
  },
  {
    id: 'garden-waste',
    title: 'Garden waste subscription',
    service: 'collections',
    body: 'A garden waste subscription costs £48 a year and covers a fortnightly collection from March to November.',
  },
])
 
const helplineTools = {
  readPage: tool({
    description: 'Read one page of the waste and recycling guidance by its slug.',
    inputSchema: z.object({ slug: z.string() }),
    execute: async ({ slug }: { slug: string }): Promise<WastePage | null> => {
      const page = await narsil.get('waste_pages', slug)
      if (page === undefined) return null
      return { title: String(page.title), body: String(page.body) }
    },
  }),
}
 
const askHelpline = async (): Promise<string> => {
  const helpline = new ToolLoopAgent({
    model: helplineModel(),
    instructions: 'Answer the resident from the waste and recycling guidance.',
    tools: helplineTools,
  })
  const reply = await helpline.generate({ prompt: QUESTION })
  return reply.text
}
 
console.log(JSON.stringify({ question: QUESTION, helplineAnswered: await askHelpline() }, null, 2))
 
await narsil.shutdown()
{
  "question": "How do I get rid of an old fridge?",
  "helplineAnswered": "I hold no page under 'fridge-disposal'."
}

The guidance contains the answer twice, on the bulky waste page and on the recycling centre page, yet the resident hears neither. Keep a lookup by key alongside a search all the same, because once a search returns a page's id, the model can use the lookup to open that page.

Search on the words that a resident uses

searchPages takes the question as text and returns the pages that match it. query passes that text through the index's language module, which stems each word and drops the stop words, and the terms that remain are what a page can match. Two pages contain the word fridge, which is why the search returns both.

import { createNarsil } from '@delali/narsil'
import { ToolLoopAgent, tool } from 'ai'
import { z } from 'zod'
import { helplineModel, QUESTION, type WastePage } from './mock-model.ts'
 
const narsil = await createNarsil()
 
await narsil.createIndex('waste_pages', {
  schema: { title: 'string', body: 'string', service: 'enum' },
  language: 'english',
})
 
await narsil.insertBatch('waste_pages', [
  {
    id: 'bulky-waste',
    title: 'Book a bulky waste collection',
    service: 'collections',
    body: 'A fridge, a freezer, a mattress, or a sofa goes on a bulky waste collection, which costs £22 for up to three items. The crew takes the items from the kerbside on the morning of the booking.',
  },
  {
    id: 'recycling-centre',
    title: 'Using the household recycling centre',
    service: 'sites',
    body: 'The household recycling centre on Marsh Lane takes a fridge, garden waste, and rubble free of charge from any resident holding a site permit.',
  },
  {
    id: 'missed-collection',
    title: 'Report a missed collection',
    service: 'collections',
    body: 'Report a missed bin within two working days and the crew returns to the street on the next working day.',
  },
  {
    id: 'food-waste',
    title: 'Food waste caddies',
    service: 'collections',
    body: 'Every household has a kitchen caddy and a kerbside caddy for food waste, emptied each week.',
  },
  {
    id: 'small-electricals',
    title: 'Small electrical items',
    service: 'sites',
    body: 'A kettle, a toaster, or a hairdryer goes in the small electricals bank at any of the four recycling banks.',
  },
  {
    id: 'garden-waste',
    title: 'Garden waste subscription',
    service: 'collections',
    body: 'A garden waste subscription costs £48 a year and covers a fortnightly collection from March to November.',
  },
])
 
const helplineTools = {
  readPage: tool({ 
    description: 'Read one page of the waste and recycling guidance by its slug.', 
    inputSchema: z.object({ slug: z.string() }), 
    execute: async ({ slug }: { slug: string }): Promise<WastePage | null> => { 
      const page = await narsil.get('waste_pages', slug) 
      if (page === undefined) return null
      return { title: String(page.title), body: String(page.body) } 
  searchPages: tool({ 
    description: 'Search the waste and recycling guidance by what its text says.',
    inputSchema: z.object({ text: z.string() }),
    execute: async ({ text }: { text: string }): Promise<WastePage[]> => {
      const found = await narsil.query<WastePage>('waste_pages', { term: text, limit: 5 })
      return found.hits.map((hit) => hit.document)
    },
  }),
}
 
const askHelpline = async (): Promise<string> => {
  const helpline = new ToolLoopAgent({
    model: helplineModel(),
    instructions: 'Answer the resident from the waste and recycling guidance.',
    tools: helplineTools,
  })
  const reply = await helpline.generate({ prompt: QUESTION })
  return reply.text
}
 
console.log(JSON.stringify({ question: QUESTION, helplineAnswered: await askHelpline() }, null, 2))
 
await narsil.shutdown()
{
  "question": "How do I get rid of an old fridge?",
  "helplineAnswered": "The household recycling centre on Marsh Lane takes a fridge, garden waste, and rubble free of charge from any resident holding a site permit. A fridge, a freezer, a mattress, or a sofa goes on a bulky waste collection, which costs £22 for up to three items. The crew takes the items from the kerbside on the morning of the booking."
}

The resident now hears both ways to get rid of the fridge. Two choices in that tool are still yours to make. The tool returns each hit as the whole stored document, which means that the next prompt contains every field of every page. It also returns no score, and without one the model has no way to tell the weaker page from the stronger one.

Narrow by service, and fix the rest in the tool

You control what the model receives with four query options. document.include lists the two fields that the tool sends, and the tool sets limit itself, where the model has no way to raise it. filters removes candidates before the engine scores anything, while facets returns a count of the matching pages in each service area along with the hits.

SERVICE_AREAS holds the council's own names for its service areas, and z.enum puts that list in the tool's input schema, which leaves the model only those values to choose from. The facet counts hold one match in each area, and on its second call the model narrows the search to collections. On a single thread the engine returns each facet count with errorBound: 0 and countExact: true, which means that each count is the exact number of matching pages.

import { createNarsil } from '@delali/narsil'
import { ToolLoopAgent, tool } from 'ai'
import { z } from 'zod'
import { helplineModel, QUESTION, type WastePage } from './mock-model.ts'
import { helplineModel, type PageSearch, QUESTION, type WastePage } from './mock-model.ts'
 
const SERVICE_AREAS = ['collections', 'sites'] as const
 
const narsil = await createNarsil()
 
await narsil.createIndex('waste_pages', {
  schema: { title: 'string', body: 'string', service: 'enum' },
  language: 'english',
})
 
await narsil.insertBatch('waste_pages', [
  {
    id: 'bulky-waste',
    title: 'Book a bulky waste collection',
    service: 'collections',
    body: 'A fridge, a freezer, a mattress, or a sofa goes on a bulky waste collection, which costs £22 for up to three items. The crew takes the items from the kerbside on the morning of the booking.',
  },
  {
    id: 'recycling-centre',
    title: 'Using the household recycling centre',
    service: 'sites',
    body: 'The household recycling centre on Marsh Lane takes a fridge, garden waste, and rubble free of charge from any resident holding a site permit.',
  },
  {
    id: 'missed-collection',
    title: 'Report a missed collection',
    service: 'collections',
    body: 'Report a missed bin within two working days and the crew returns to the street on the next working day.',
  },
  {
    id: 'food-waste',
    title: 'Food waste caddies',
    service: 'collections',
    body: 'Every household has a kitchen caddy and a kerbside caddy for food waste, emptied each week.',
  },
  {
    id: 'small-electricals',
    title: 'Small electrical items',
    service: 'sites',
    body: 'A kettle, a toaster, or a hairdryer goes in the small electricals bank at any of the four recycling banks.',
  },
  {
    id: 'garden-waste',
    title: 'Garden waste subscription',
    service: 'collections',
    body: 'A garden waste subscription costs £48 a year and covers a fortnightly collection from March to November.',
  },
])
 
const helplineTools = {
  searchPages: tool({
    description: 'Search the waste and recycling guidance by what its text says.',
    inputSchema: z.object({ text: z.string() }), 
    execute: async ({ text }: { text: string }): Promise<WastePage[]> => { 
      const found = await narsil.query<WastePage>('waste_pages', { term: text, limit: 5 }) 
      return found.hits.map((hit) => hit.document) 
    inputSchema: z.object({ text: z.string(), service: z.enum(SERVICE_AREAS).optional() }), 
    execute: async ({
      text,
      service,
    }: {
      text: string
      service?: (typeof SERVICE_AREAS)[number]
    }): Promise<PageSearch> => {
      const found = await narsil.query<WastePage>('waste_pages', {
        term: text,
        filters: service === undefined ? undefined : { fields: { service: { eq: service } } },
        facets: { service: {} },
        document: { include: ['title', 'body'] },
        limit: 3,
      })
      return {
        hits: found.hits.map((hit) => ({
          page: hit.id,
          title: hit.document.title,
          body: hit.document.body,
          score: Number((hit.score ?? 0).toFixed(2)),
        })),
        services: found.facets?.service.values ?? {},
      }
    },
  }),
}
 
const askHelpline = async (): Promise<string> => {
  const helpline = new ToolLoopAgent({
    model: helplineModel(),
    instructions: 'Answer the resident from the waste and recycling guidance.', 
    instructions: 'Answer the resident from the waste and recycling guidance, and say which page you used.', 
    tools: helplineTools,
  })
  const reply = await helpline.generate({ prompt: QUESTION })
  return reply.text
}
 
console.log(JSON.stringify({ question: QUESTION, helplineAnswered: await askHelpline() }, null, 2))
 
await narsil.shutdown()
{
  "question": "How do I get rid of an old fridge?",
  "helplineAnswered": "A fridge appears on 1 collections page and 1 sites page. See 'Book a bulky waste collection'. A fridge, a freezer, a mattress, or a sofa goes on a bulky waste collection, which costs £22 for up to three items. The crew takes the items from the kerbside on the morning of the booking."
}

The model chooses the search text itself and cuts the question down to the one word that a page can match. On its second call it narrows the search by service, and its answer contains the page title along with the charge.

  1. Model callsreadPage({ slug: 'fridge-disposal' })
  2. Tool returnsnull
  3. Model callssearchPages({ text: 'fridge' })
  4. Tool returns2 hits, services: { collections: 1, sites: 1 }
  5. Model callssearchPages({ text: 'fridge', service: 'collections' })
  6. Tool returns1 hit: bulky-waste
The model's calls are on the left and the tool's results are on the right, in the order of the tool loop. The guessed slug returns null, whereas each search returns pages.

Where to go next