Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ Browser tab
├── Answer check ────► every reply, read back against the tool results
└── Tool loop ───────► search provider (DuckDuckGo, Wikipedia, or LangSearch/Jina with your key)
├► r.jina.ai (page reader)
├► MediaWiki (Wikipedia pages, no reader budget)
└► MCP servers over HTTP
```

Expand Down Expand Up @@ -205,6 +206,7 @@ Three details make the app work from a repository sub-path rather than a domain
| -------------- | ------------------------------------------------------------------- |
| `web_search` | Full web search with no key; Wikipedia, LangSearch or Jina instead. |
| `read_page` | Fetches a URL and returns its readable text. |
| `research` | Search, read three independent sites, return quoted passages. |
| `calculator` | Exact arithmetic via a hand-written parser. |
| `current_time` | Local date, time, and timezone. |
| `weather` | Current conditions and a three-day outlook, from several forecasts. |
Expand Down Expand Up @@ -233,7 +235,7 @@ Search and `read_page` share the reader's budget of 20 requests a minute per IP,

**The search itself now carries the facts a 0.8B model would otherwise spend a round guessing at.** Every `web_search` result is stamped with today's local date, so "current" and "today's news" have a date without calling `current_time`. A German question searches German Wikipedia and, on DuckDuckGo, prefers German results (`kl=de-de`); English _who was Ada Lovelace_ is not mistaken for German because bare `was` is also English. German Wikipedia is smaller, so an empty result there falls through to English rather than telling the model the subject does not exist.

The `research-question` skill used to teach only "search, then answer from the snippet". That is how Wikipedia's lead about an office never naming the incumbent became a wrong answer. It now also shows opening the page when the snippet is not enough, and answering a German office-holder question in German from a German source.
The `research-question` skill does not offer `web_search`. It offers `research`, which searches, picks three **different sites** (`investor.nvidia.com` and `nvidianews.nvidia.com` count as one), reads them in parallel, and returns the passages that bear on the question. Wikipedia pages go through MediaWiki, so a typical call spends one reader request on the search and two on the other sites rather than six. A page that will not open becomes its search snippet; if nothing readable comes back the tool throws rather than telling the model the subject does not exist. `lookup-term` still searches and optionally reads one page — a name does not need three sources.

**LangSearch is the way off that shared budget without paying for one.** `api.langsearch.com` is a search API rather than a results page, its free tier allows 1,000 searches a day and one a second, and a key needs no card — so a search stops competing with `read_page` for the same 20 requests a minute. Two things about it are worth knowing before choosing it. Its snippets are index text rather than prose, lower-cased and with spaces around the punctuation, which a 0.8B model reads less confidently than a sentence. And it answers in an envelope: a refusal it decides to report with a 200 arrives as a `msg` and no result set, so `searchLangSearch` raises that rather than passing an empty list to a model that would relay it as "this does not exist". Long summaries are available per result and are switched off — each is the whole page behind the result, which would leave a 0.8B context with no room for the answer.

Expand Down
15 changes: 7 additions & 8 deletions src/eval/scenarios.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ export interface Scenario {
}

function searchQuery(calls: Invocation[]): string | null {
const search = calls.find((call) => call.name === 'web_search')
const search = calls.find((call) => call.name === 'web_search' || call.name === 'research')
return search ? String(search.arguments.query ?? '') : null
}

Expand Down Expand Up @@ -261,11 +261,10 @@ export const SCENARIOS: Scenario[] = [
id: 'web-current-event',
category: 'web',
prompt: 'Who is the current secretary-general of the United Nations?',
expectTool: 'web_search',
expectTool: 'research',
accept: matches(/guterres/i),
// The hardest of these under Wikipedia, whose lead extract describes the
// office and never names the incumbent: passing there needs a follow-up
// `read_page`. A web provider names him in the snippets.
// Wikipedia's lead about the office often never names the incumbent; this
// tool reads three pages, so the person page or a UN page can supply it.
online: true,
},
{
Expand Down Expand Up @@ -344,7 +343,7 @@ export const SCENARIOS: Scenario[] = [
// A price is looked up, never worked out. `how much is` was an arithmetic
// keyword, so this reached the calculator with nothing to calculate.
prompt: 'How much is a Big Mac in Japan?',
expectTool: 'web_search',
expectTool: 'research',
accept: (answer) => /\d/.test(answer),
online: true,
},
Expand All @@ -353,7 +352,7 @@ export const SCENARIOS: Scenario[] = [
category: 'web',
// `today` was a current-date trigger, which answered this with the date.
prompt: "What's today's news?",
expectTool: 'web_search',
expectTool: 'research',
accept: (answer) => answer.trim().length > 20,
online: true,
},
Expand All @@ -364,7 +363,7 @@ export const SCENARIOS: Scenario[] = [
// answer in English. The query has to keep the German word; translating it
// to "chancellor of germany" is the 1inch failure in another language.
prompt: 'Wer ist der Bundeskanzler?',
expectTool: 'web_search',
expectTool: 'research',
acceptCall: (calls) => /bundeskanzler/i.test(searchQuery(calls) ?? ''),
accept: matches(/merz|scholz|kanzler/i),
online: true,
Expand Down
4 changes: 2 additions & 2 deletions src/lib/tool-labels.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ describe('describeTool', () => {
})

it('speaks in the past tense once it is over, however it ended', () => {
expect(describeTool('read_page', 'done')).toBe('Read a page')
expect(describeTool('read_page', 'error')).toBe('Read a page')
expect(describeTool('research', 'running')).toBe('Researching')
expect(describeTool('research', 'done')).toBe('Researched')
})

it('leaves a tool it does not ship under its own name', () => {
Expand Down
1 change: 1 addition & 0 deletions src/lib/tool-labels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type { ToolCall } from '@/types'
const PHRASES: Record<string, { running: string; done: string }> = {
web_search: { running: 'Searching the web', done: 'Searched the web' },
read_page: { running: 'Reading a page', done: 'Read a page' },
research: { running: 'Researching', done: 'Researched' },
calculator: { running: 'Calculating', done: 'Calculated' },
current_time: { running: 'Checking the date', done: 'Checked the date' },
weather: { running: 'Checking the weather', done: 'Checked the weather' },
Expand Down
4 changes: 2 additions & 2 deletions src/skills/library.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ describe('the shipped library', () => {
['current-date', 25, ['current_time']],
['summarize-url', 20, ['read_page']],
['lookup-term', 15, ['web_search', 'read_page']],
['research-question', 10, ['web_search', 'read_page']],
['research-question', 10, ['research']],
])
})

Expand Down Expand Up @@ -427,7 +427,7 @@ describe('activating each shipped skill', () => {
['What year is it?', 'current-date', ['current_time']],
['What does https://example.com/pricing say?', 'summarize-url', ['read_page']],
['What is Stripe?', 'lookup-term', ['web_search', 'read_page']],
['Who is the current secretary-general of the UN?', 'research-question', ['web_search', 'read_page']],
['Who is the current secretary-general of the UN?', 'research-question', ['research']],
]

it.each(cases)('materialises %s for %j with only its tools', (message, name, tools) => {
Expand Down
58 changes: 17 additions & 41 deletions src/skills/research-question/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
---
name: research-question
description: Answers a question about current or verifiable facts by searching the web, opening the most promising result and citing it. Use for anything recent, anything about a named person or organisation, and anything you would otherwise be guessing at.
description: Answers a question about current or verifiable facts by researching it across several independent sources and citing them. Use for anything recent, anything about a named person or organisation, and anything you would otherwise be guessing at.
jarvis:
priority: 10
tools:
- web_search
- read_page
- research
keywords:
- look it up
- find out
Expand Down Expand Up @@ -53,61 +52,38 @@ jarvis:
# thermometer must not be answered with a search.
- '^\s*aktuelle[rs]?\s+(?!(uhrzeit|zeit|datum|temperatur|wetter|jahr|tag|woche|monat)\b)[a-zäöüß]{3,}\s*\??\s*$'
exemplars:
# The failure this exists to catch: Wikipedia's lead names the office and
# not the person, so answering from the snippet invents or omits the name.
- user: Who is the current secretary-general of the UN?
steps:
- tool: web_search
- tool: research
arguments:
query: current United Nations secretary-general
result: |
Searched 2026-08-26 for "current United Nations secretary-general".
Researched 2026-08-26 for "current United Nations secretary-general" across 3 sources, all read in full.

1. Secretary-General of the United Nations - Wikipedia
https://en.wikipedia.org/wiki/Secretary-General_of_the_United_Nations
The secretary-general is the chief administrative officer of the United Nations.
- tool: read_page
arguments:
url: https://en.wikipedia.org/wiki/Secretary-General_of_the_United_Nations
result: |
# Secretary-General of the United Nations
Source: https://en.wikipedia.org/wiki/Secretary-General_of_the_United_Nations

António Guterres of Portugal has served as secretary-general since 2017.
1. António Guterres - Wikipedia — https://en.wikipedia.org/wiki/António_Guterres
"António Guterres has served as secretary-general of the United Nations since 2017."
2. Secretary-General | United Nations — https://www.un.org/sg/en
"António Guterres is the ninth Secretary-General of the United Nations."
answer: |
António Guterres, since 2017.

Source: https://en.wikipedia.org/wiki/Secretary-General_of_the_United_Nations
- user: Who is the chief executive of Fictional Airways?
steps:
- tool: web_search
arguments:
query: Fictional Airways chief executive
result: |
Searched 2026-08-26 for "Fictional Airways chief executive".

1. Leadership — Fictional Airways
https://fictionalairways.example/leadership
Ama Osei has led Fictional Airways as chief executive since 2023.
answer: |
Ama Osei, chief executive since 2023.

Source: https://fictionalairways.example/leadership
Source: https://en.wikipedia.org/wiki/António_Guterres https://www.un.org/sg/en
- user: Wer ist der Bundeskanzler?
steps:
- tool: web_search
- tool: research
arguments:
query: Bundeskanzler
result: |
Searched 2026-08-26 for "Bundeskanzler".
Researched 2026-08-26 for "Bundeskanzler" across 3 sources, all read in full.

1. Bundeskanzler (Deutschland) – Wikipedia
https://de.wikipedia.org/wiki/Bundeskanzler_(Deutschland)
Friedrich Merz ist seit Mai 2025 Bundeskanzler der Bundesrepublik Deutschland.
1. Bundeskanzler (Deutschland) – Wikipedia — https://de.wikipedia.org/wiki/Bundeskanzler_(Deutschland)
"Friedrich Merz ist seit dem 6. Mai 2025 Bundeskanzler der Bundesrepublik Deutschland."
2. Bundeskanzler.de — https://www.bundeskanzler.de
"Friedrich Merz führt die Bundesregierung."
answer: |
Friedrich Merz, seit Mai 2025.

Source: https://de.wikipedia.org/wiki/Bundeskanzler_(Deutschland)
Source: https://de.wikipedia.org/wiki/Bundeskanzler_(Deutschland) https://www.bundeskanzler.de
---

Search first, then answer from the results. Open a result with `read_page` when the snippet does not name the answer. Answer in the language you were asked. Always end with the source URL.
Call `research` once. Answer from the quoted passages, in the language you were asked. Cite more than one source URL when several came back.
10 changes: 10 additions & 0 deletions src/tools/builtins.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,16 @@ describe('web_search', () => {
expect(description('langsearch')).toMatch(/Search the web/)
})

it('refuses an empty research query without asking the network', async () => {
const fetchMock = vi.fn()
vi.stubGlobal('fetch', fetchMock)
const tool = createBuiltinTools(DEFAULT_WEB_ACCESS).find(
(candidate) => candidate.schema.function.name === 'research',
)!
await expect(tool.execute({ query: ' ' })).rejects.toThrow('query must not be empty')
expect(fetchMock).not.toHaveBeenCalled()
})

it('stamps today on the results so a current-events answer has a date', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-08-26T15:00:00'))
Expand Down
27 changes: 26 additions & 1 deletion src/tools/builtins.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { evaluateExpression } from './calculator'
import { memory } from './memory'
import { researchQuestion } from './research'
import { defineTool, type Tool } from './types'
import { DEFAULT_WEB_ACCESS, readPage, searchWeb, type SearchProvider, type WebAccessConfig } from './web'
import { weatherReport } from './weather'
Expand Down Expand Up @@ -136,6 +137,23 @@ export const currentTime = defineTool(
},
)

function createResearch(config: WebAccessConfig): Tool {
return defineTool(
'research',
'Search the web, read the three most independent results and return quoted passages from each. Use for current events, people, organisations, or anything you would otherwise be guessing at.',
{
type: 'object',
properties: { query: { type: 'string', description: 'The question to research' } },
required: ['query'],
},
async (args) => {
const query = String(args.query ?? '').trim()
if (!query) throw new Error('query must not be empty')
return researchQuestion(query, config)
},
)
}

/**
* The network tools close over the current provider settings, so they are
* rebuilt when those change. Every tool ships in every deployment: none of them
Expand All @@ -147,7 +165,14 @@ export const currentTime = defineTool(
* who asked not to be remembered.
*/
export function createBuiltinTools(config: WebAccessConfig, options: { memory?: boolean } = {}): Tool[] {
const tools = [createWebSearch(config), createReadPage(config), calculator, currentTime, weather]
const tools = [
createWebSearch(config),
createReadPage(config),
createResearch(config),
calculator,
currentTime,
weather,
]
return options.memory === false ? tools : [...tools, memory]
}

Expand Down
Loading