Skip to content
Draft
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
8 changes: 6 additions & 2 deletions .cursor/rules/tools.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,13 @@ alwaysApply: false
Read `.cursor/skills/add-agent-tool/SKILL.md` before changing a tool.

- Tool arguments reach `execute` as untyped, trimmed strings, because the model emits XML. Coerce
and validate every one.
and validate every one, and read a phrase the model wrote into one argument rather than refusing
it: a refusal costs the whole round.
- `execute` gets a second argument, `ToolContext`, carrying the user's question for this turn. It is
optional by design — use it, as `read_page` does to pick the passages worth reading, but never
require it.
- The return value is fed straight back into a 0.8B model's context. Keep it compact and cap
anything that could be unbounded.
anything that could be unbounded at `MAX_PAGE_CHARS` from `src/tools/extract.ts`.
- There is no proxy. A tool that needs the network must call an endpoint that sends CORS headers,
in `src/tools/web.ts`, or it cannot run in a browser at all.
- A new tool needs a scenario in `src/eval/scenarios.ts`, or nothing measures whether the model
Expand Down
16 changes: 12 additions & 4 deletions .cursor/skills/add-agent-tool/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,20 @@ handed to `runAgent`, which matches the model's request against `schema.function
- **Arguments are untyped strings.** Qwen3.5 emits tool calls as XML, and `src/agent/parse.ts`
passes every `<parameter>` value through as a trimmed string. `execute` receives
`Record<string, unknown>`. Coerce and validate everything yourself — `Number(args.limit)` may be
`NaN`, `args.query` may be absent.
`NaN`, `args.query` may be absent. Be generous about what you accept: a rejected call spends the
whole round, and `normalizeExpression` in `calculator.ts`, `placeCandidates` in `weather.ts` and
`readConversionRequest` in `units.ts` all exist because the model wrote something readable that
was refused.
- **The return value goes straight into the model's context, so cap it.** Return a compact string,
not JSON, not megabytes. Long tool results are not a neutral cost: function-calling accuracy falls
by 7% to 91% as responses grow, which is why `read_page` truncates at `MAX_PAGE_CHARS` (8,000
characters, roughly 2,000 tokens). Anything that can return an unbounded body needs the same
treatment.
by 7% to 91% as responses grow, which is why `read_page` and MCP results are both held to
`MAX_PAGE_CHARS` (8,000 characters, roughly 2,000 tokens, exported from `src/tools/extract.ts`).
Anything that can return an unbounded body needs the same treatment.
- **`execute` also receives a `ToolContext`**, holding the user's question for this turn. It comes
from the agent loop rather than from the model, so a tool can use what the turn is about without
spending an argument on it — `read_page` picks the passages of a long page that answer the
question that way. Ignore it if you have no use for it; never require it, since the eval harness
and a wind-down round can both call a tool with nothing useful in it.
- **Throw on failure.** `runAgent` catches it and feeds `Tool "<name>" failed: <message>` back to
the model, which usually recovers. Never return an error string that reads like a result.
- **The description is prompt text.** The chat template renders it into every prompt. Write one or
Expand Down
66 changes: 55 additions & 11 deletions README.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion src/agent/budget.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { budgetFallback, callFingerprint, repeatedCallNote, windDownNote } from
import type { ReviewEvidence } from './review'

function evidence(results: { tool: string; result: string }[] = []): ReviewEvidence {
return { toolResults: results, knownUrls: [] }
return { toolResults: results, knownUrls: [], question: 'Who leads Fictional Airways?' }
}

const searchResult = {
Expand Down
25 changes: 24 additions & 1 deletion src/agent/loop.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ describe('runAgent', () => {

const result = await runAgent(fakeClient([toolCall, 'done</think>2 + 2 = 4']), turns, [calculator], hooks)

expect(execute).toHaveBeenCalledWith({ expression: '2+2' })
expect(execute).toHaveBeenCalledWith({ expression: '2+2' }, { question: 'hi' })
expect(result.content).toBe('2 + 2 = 4')
// The first round had no visible content, but its reasoning must not become an answer.
expect(hooks.onRoundEnd).toHaveBeenNthCalledWith(
Expand All @@ -71,6 +71,29 @@ describe('runAgent', () => {
)
})

it('hands a tool the question, from the turn the user actually wrote', async () => {
// `read_page` decides which part of a long page is worth the context from
// this. It has to be the user's turn and not the last `user` turn in the
// conversation, which by the wind-down round is a prompt the loop wrote.
const execute = vi.fn(async () => 'page')
const reader = defineTool('read_page', 'read', { type: 'object', properties: {} }, execute)
const asked = [
{ role: 'user' as const, content: 'What is 1inch?' },
{ role: 'assistant' as const, content: 'A DEX aggregator.' },
{ role: 'user' as const, content: 'What does it charge?' },
]

await runAgent(
fakeClient([toolCall('read_page', 'url', 'https://example.com'), 'done</think>Nothing.']),
asked,
[reader],
callbacks(),
{ review: false },
)

expect(execute).toHaveBeenCalledWith({ url: 'https://example.com' }, { question: 'What does it charge?' })
})

it('stops calling tools after the round budget rather than looping', async () => {
const search = defineTool(
'web_search',
Expand Down
8 changes: 7 additions & 1 deletion src/agent/loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,12 @@ export async function runAgent(
const strategy = options.strategy ?? DEFAULT_STRATEGY
const checking = options.review ?? true
const evidence = collectEvidence(turns)
/**
* Read once, before the loop adds turns of its own: the wind-down prompt and
* a correction request are both `user` turns, and neither is what the user
* asked. Tools that can use the question get this one.
*/
const question = turns.findLast((turn) => turn.role === 'user')?.content ?? ''

let last: AgentResult = {
content: '',
Expand Down Expand Up @@ -233,7 +239,7 @@ export async function runAgent(
}

try {
const result = await tool.execute(call.arguments)
const result = await tool.execute(call.arguments, { question })
executed.set(fingerprint, result)
callbacks.onToolEnd(id, { result, durationMs: performance.now() - startedAt })
conversation.push({ role: 'tool', content: result })
Expand Down
65 changes: 64 additions & 1 deletion src/agent/review.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'
import { collectEvidence, correctionPrompt, reviewAnswer, type ReviewEvidence } from './review'

function evidence(overrides: Partial<ReviewEvidence> = {}): ReviewEvidence {
return { toolResults: [], knownUrls: [], ...overrides }
return { toolResults: [], knownUrls: [], question: '', ...overrides }
}

const searchResult = {
Expand Down Expand Up @@ -61,6 +61,16 @@ describe('reviewAnswer', () => {
expect(checks('I could not work that out.', failed)).toEqual([])
})

it('holds a conversion to the same standard, unit and all', () => {
// `convert` writes its result as `5 mi = 8.04672 km`, so the number is not
// the last thing on the line and used to be read as no number at all.
const converted = evidence({ toolResults: [{ tool: 'convert', result: '5 mi = 8.04672 km' }] })

expect(checks('5 miles is 8.05 km.', converted)).toEqual([])
expect(checks('5 miles is roughly 8 kilometres.', converted)).toEqual(['wrong-number'])
expect(reviewAnswer('About 8 km.', converted)[0]?.instruction).toContain('The conversion returned')
})

it('reports one correction however many sums were dropped', () => {
const two = evidence({
toolResults: [
Expand All @@ -73,6 +83,58 @@ describe('reviewAnswer', () => {
})
})

/**
* The system prompt asks for the language the user wrote in, and this model
* drifts back to English mid-conversation. The evidence is the question, so
* this is settled without asking the model anything.
*/
describe('language', () => {
const germanQuestion = evidence({ question: 'Wie hoch ist der Eiffelturm und wann wurde er gebaut?' })

it('catches an English answer to a German question', () => {
expect(
checks(
'The Eiffel Tower is 330 metres tall, and it was completed in 1889 for the World Fair.',
germanQuestion,
),
).toEqual(['wrong-language'])
})

it('says which language to use, in that language', () => {
const [finding] = reviewAnswer('The tower is 330 metres tall and was built in 1889.', germanQuestion)

expect(finding?.instruction).toBe('Die Frage war auf Deutsch. Antworte auf Deutsch.')
})

it('accepts a German answer to a German question', () => {
expect(checks('Der Eiffelturm ist 330 Meter hoch und wurde 1889 gebaut.', germanQuestion)).toEqual([])
})

it('catches it the other way round too', () => {
const asked = evidence({ question: 'How tall is the Eiffel Tower and when was it built?' })

expect(checks('Der Eiffelturm ist 330 Meter hoch und wurde 1889 gebaut.', asked)).toEqual([
'wrong-language',
])
})

it.each([
// Too short to have a language, which is most correct answers.
['330 Meter.', 'Wie hoch ist der Eiffelturm?'],
['Paris', 'Was ist die Hauptstadt von Frankreich?'],
['Ja, 8,05 km.', 'Wie viel sind 5 Meilen in Kilometer?'],
// A German answer quoting an English source is still a German answer.
[
'Der Turm ist 330 Meter hoch. Die Quelle schreibt "the tower was completed in 1889".',
'Wie hoch ist der Eiffelturm?',
],
// Neither language is one this can read, so it says nothing at all.
['La tour Eiffel mesure 330 mètres de haut.', 'Quelle est la hauteur de la tour Eiffel ?'],
])('leaves %j alone', (answer, question) => {
expect(checks(answer, evidence({ question }))).toEqual([])
})
})

describe('sources', () => {
const searched = evidence({ toolResults: [searchResult] })

Expand Down Expand Up @@ -177,6 +239,7 @@ describe('collectEvidence', () => {
// worked example is exactly the kind of thing the model should not cite.
expect(collected).toEqual({
toolResults: [],
question: 'Summarise https://example.com/pricing',
knownUrls: ['https://example.com/pricing', 'https://example.com/old'],
})
})
Expand Down
Loading