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
3 changes: 2 additions & 1 deletion .cursor/skills/agent-memory/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,8 @@ what counts as a repeat, where a false positive would silently drop what the use
conversation; here that call runs on the user's own GPU and would double the cost of a turn.
- **The conversation topic is derived, never stored.** `topic.ts` reads the last weather place or
the last research subject off the transcript and injects one line, the same way recall is
injected. Recency wins, so an older Frankfurt does not leak onto _und der von Frankreich?_. It
injected. Recency wins, so an older Frankfurt does not leak onto _und der von Frankreich?_. A
correction that only names a place — _nein in Russland_ — keeps the last office the same way. It
fires on a follow-up, a weather turn with no place, or an anaphor (_der Bürgermeister_, _there_).
It stays off a fresh named subject — _Wer ist Elon Musk?_ after Frankfurt weather must not
receive Frankfurt, and _und Elon Musk?_ after a chancellor turn must not receive Bundeskanzler.
Expand Down
32 changes: 17 additions & 15 deletions .cursor/skills/debug-model-output/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,21 +9,23 @@ license: MIT
Most of these symptoms have been hit before and have a known cause. Check this table before
changing sampling parameters or the system prompt.

| Symptom | Cause | Where |
| -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- |
| Reply renders empty | The model reasoned to a conclusion inside `<think>` and stopped without restating it | `promoteReasoningIfEmpty` in `src/agent/loop.ts` |
| Model keeps going and writes the user's next turn | Published `generation_config.json` lists only `<\|endoftext\|>`, so generation runs past `<\|im_end\|>` | `endOfTurnTokens()` in `src/llm/worker.ts` |
| Model answers instantly, invents arithmetic, skips tools | Reasoning is off, so the template writes an empty `<think></think>` pair into the prompt | `tokenizer_encode_kwargs: { enable_thinking: true }` in `src/llm/worker.ts` |
| `<tool_call>` or `<\|im_end\|>` visible in the chat | Parser is not stripping it | `parseModelOutput` / `SPECIAL_TOKEN` in `src/agent/parse.ts` |
| Tool call is emitted but never executed | Qwen3.5 emits XML, not JSON | `parseXmlToolCall` in `src/agent/parse.ts` |
| Reasoning shows up as the answer on a normal turn | Promotion is only correct when the turn is over | `parsed.toolCalls.length === 0` guard in `loop.ts` |
| Tool receives `"5"` where a number was expected | XML parameters carry no types | Coerce in the tool — see `add-agent-tool` |
| Model asks for a tool that does not exist | Usually long reasoning, not a broken tool list | `Unknown tool` branch in `loop.ts`; measured as `hallucination` in `src/eval` |
| One turn generates twice and the reply is retyped | The answer failed a check and a correction was requested | `reviewAnswer` in `src/agent/review.ts`, wired in `loop.ts` |
| A correction was generated and then thrown away | It left as many problems as the draft, so the draft stands | `settle` in `src/agent/loop.ts` |
| A turn searched four times and then answered anyway | The tool budget ran out, so the tools were withheld and the model asked to conclude | `FINAL_ANSWER_PROMPT` in `src/agent/budget.ts`, `windDown` in `loop.ts` |
| The model asked for a search and the card shows a note | It had already run that exact call this turn, so the earlier result came back instead | `callFingerprint` / `repeatedCallNote` in `src/agent/budget.ts` |
| A reply is a sentence and a list of links | Even the wind-down round came back empty, so the sources it found were handed over | `budgetFallback` in `src/agent/budget.ts` |
| Symptom | Cause | Where |
| -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- |
| Reply renders empty | The model reasoned to a conclusion inside `<think>` and stopped without restating it | `promoteReasoningIfEmpty` in `src/agent/loop.ts` |
| Model keeps going and writes the user's next turn | Published `generation_config.json` lists only `<\|endoftext\|>`, so generation runs past `<\|im_end\|>` | `endOfTurnTokens()` in `src/llm/worker.ts` |
| Model answers instantly, invents arithmetic, skips tools | Reasoning is off, so the template writes an empty `<think></think>` pair into the prompt | `tokenizer_encode_kwargs: { enable_thinking: true }` in `src/llm/worker.ts` |
| `<tool_call>` or `<\|im_end\|>` visible in the chat | Parser is not stripping it | `parseModelOutput` / `SPECIAL_TOKEN` in `src/agent/parse.ts` |
| Tool call is emitted but never executed | Qwen3.5 emits XML, not JSON | `parseXmlToolCall` in `src/agent/parse.ts` |
| Reasoning shows up as the answer on a normal turn | Promotion is only correct when the turn is over | `parsed.toolCalls.length === 0` guard in `loop.ts` |
| Tool receives `"5"` where a number was expected | XML parameters carry no types | Coerce in the tool — see `add-agent-tool` |
| Model asks for a tool that does not exist | Usually long reasoning, not a broken tool list | `Unknown tool` branch in `loop.ts`; measured as `hallucination` in `src/eval` |
| One turn generates twice and the reply is retyped | The answer failed a check and a correction was requested | `reviewAnswer` in `src/agent/review.ts`, wired in `loop.ts` |
| A correction was generated and then thrown away | It left as many problems as the draft, so the draft stands | `settle` in `src/agent/loop.ts` |
| A turn searched four times and then answered anyway | The tool budget ran out, so the tools were withheld and the model asked to conclude | `FINAL_ANSWER_PROMPT` in `src/agent/budget.ts`, `windDown` in `loop.ts` |
| The model asked for a search and the card shows a note | It had already run that exact call this turn, so the earlier result came back instead | `callFingerprint` / `repeatedCallNote` in `src/agent/budget.ts` |
| A reply is a sentence and a list of links | Even the wind-down round came back empty, so the sources it found were handed over | `budgetFallback` in `src/agent/budget.ts` |
| German office holder for another country's president | `extractAnswer` treated any Amtsträger line as the answer; a nationality in the question now has to appear in that source | `fitsAskedPlace` in `src/tools/research.ts` |
| Follow-up says "I believe" and skips research | Correction fragments kept the skill and not the tool; the skill now has a _nein in Russland_ exemplar | `src/skills/research-question/SKILL.md`, `conversationTopic` |

## Facts that are easy to get wrong

Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,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 does not offer `web_search`. It offers `research`, which searches the live web and Wikipedia together, picks three **different sites** (Wikipedia first when it appeared, because MediaWiki is free and the lead paragraph usually answers; `investor.nvidia.com` and `nvidianews.nvidia.com` count as one), ranks the rest by whether their snippet already bears on the question, and reads them in parallel. A page that will not open, or that comes back as a login wall or a firewall interstitial, is replaced from the remaining hits rather than quoted; only when nothing else is left does the search snippet stand in. Inflected forms of a word still match — _Bundeskanzlers_ answers _Bundeskanzler_ — so a German page is not silent on a German question. The search query is narrowed the way the weather tool narrows a place: _What is the capital of France?_ is searched as `capital of France`, which ranks Paris rather than a page that uses the sentence as an example of a question; _why_ and _how_ keep the shell, because _sky blue_ ranks a colour swatch and _why is the sky blue_ ranks the physics. Wikipedia's own ranking still puts a list and _capital punishment_ ahead of Paris; the article whose short description _is_ the query takes the free first slot, and a Wikimedia list is pushed behind it. A one-line Wikipedia claim that names the incumbent (_Amtsträger ist seit dem 6. Mai 2025 Friedrich Merz_) is kept even when the lead is a definition of the office. When several passages name the same person or place, or one sentence states it as the predicate of the question (_Paris is the capital_, _Amtsträger ist … Friedrich Merz_), the digest opens with that as `Answer: Friedrich Merz.` so a 0.8B model can copy the line rather than extract it from a list. A population or a price gets the same treatment — `Answer: 13.96 million people.` — because those are the figures a 0.8B model otherwise invents to three significant digits. A name that is the subject of the question (_Who is Elon Musk?_) is not an answer and is left off; neither is the city on a population question, nor a tie between two different names or two figures that are not the same reading. If nothing readable comes back the tool throws rather than telling the model the subject does not exist.
The `research-question` skill does not offer `web_search`. It offers `research`, which searches the live web and Wikipedia together, picks three **different sites** (Wikipedia first when it appeared, because MediaWiki is free and the lead paragraph usually answers; `investor.nvidia.com` and `nvidianews.nvidia.com` count as one), ranks the rest by whether their snippet already bears on the question, and reads them in parallel. A page that will not open, or that comes back as a login wall or a firewall interstitial, is replaced from the remaining hits rather than quoted; only when nothing else is left does the search snippet stand in. Inflected forms of a word still match — _Bundeskanzlers_ answers _Bundeskanzler_ — so a German page is not silent on a German question. The search query is narrowed the way the weather tool narrows a place: _What is the capital of France?_ is searched as `capital of France`, which ranks Paris rather than a page that uses the sentence as an example of a question; _why_ and _how_ keep the shell, because _sky blue_ ranks a colour swatch and _why is the sky blue_ ranks the physics. Wikipedia's own ranking still puts a list and _capital punishment_ ahead of Paris; the article whose short description _is_ the query takes the free first slot, and a Wikimedia list is pushed behind it. A one-line Wikipedia claim that names the incumbent (_Amtsträger ist seit dem 6. Mai 2025 Friedrich Merz_) is kept even when the lead is a definition of the office. When several passages name the same person or place, or one sentence states it as the predicate of the question (_Paris is the capital_, _Amtsträger ist … Friedrich Merz_), the digest opens with that as `Answer: Friedrich Merz.` so a 0.8B model can copy the line rather than extract it from a list. A nationality or country in the question has to appear in the same source: _Amtsträger ist Friedrich Merz_ answers _Bundeskanzler_, not _russische Präsident_. A population or a price gets the same treatment — `Answer: 13.96 million people.` — because those are the figures a 0.8B model otherwise invents to three significant digits. A name that is the subject of the question (_Who is Elon Musk?_) is not an answer and is left off; neither is the city on a population question, nor a tie between two different names or two figures that are not the same reading. If nothing readable comes back the tool throws rather than telling the model the subject does not exist.

A factual question that no other skill claimed — _What is the capital of France?_, _Warum ist der Himmel blau?_ — still reaches this skill, as a fourth routing stage rather than a broader trigger. Greetings do not: _wie geht's dir_ and _how are you_ stay with the model. `lookup-term` still searches and optionally reads one page — a name does not need three sources.

Expand Down Expand Up @@ -342,7 +342,7 @@ Memories live in **IndexedDB**, in your browser, next to the model weights. Noth

The [CoALA taxonomy](https://arxiv.org/abs/2309.02427) splits an agent's memory four ways: working memory is the live context window, and the three durable kinds are semantic, episodic and procedural. Only the durable three are stored — working memory is the transcript, which the tab already holds — and they are named in words the model can actually pick between.

The transcript is not enough on its own. A 0.8B model will answer tomorrow's weather without the city the last turn just resolved, so the last established place is pinned into the system prompt as one short line — _This conversation is about Frankfurt._ — the same way recall is. After a research turn the same line holds the office or subject — _This conversation is about Bundeskanzler._ — so _und der von Frankreich?_ is not searched as a fragment. Recency decides which one: an older Frankfurt does not leak onto a chancellor follow-up. It is derived from the chat, never written to IndexedDB, and it stays off a fresh question that names its own subject. Switching memory off does not drop it: that toggle is for stored facts, not for this conversation.
The transcript is not enough on its own. A 0.8B model will answer tomorrow's weather without the city the last turn just resolved, so the last established place is pinned into the system prompt as one short line — _This conversation is about Frankfurt._ — the same way recall is. After a research turn the same line holds the office or subject — _This conversation is about Bundeskanzler._ — so _und der von Frankreich?_ or _nein in Russland_ is not searched as a fragment. Recency decides which one: an older Frankfurt does not leak onto a chancellor follow-up. It is derived from the chat, never written to IndexedDB, and it stays off a fresh question that names its own subject. Switching memory off does not drop it: that toggle is for stored facts, not for this conversation.

| Kind | Is | Recalled |
| ------------ | ---------------------------------- | ----------------------------- |
Expand Down
40 changes: 40 additions & 0 deletions src/eval/runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,46 @@ Use the calculator.`,
)
})

it('pins the last research subject onto a correction that only names a place', async () => {
const catalog = loadCatalog()
const client = fakeClient([
'</think><tool_call><function=research><parameter=query>Präsident Russland</parameter></function></tool_call>',
'</think>Wladimir Putin.\n\nSource: https://de.wikipedia.org/wiki/Präsident_Russlands',
])
const [attempt] = await runEval(client, {
scenarios: [
{
id: 'research-correction',
category: 'web',
history: [
{ role: 'user', content: 'Wer ist der russische Präsident?' },
{ role: 'assistant', content: 'Wladimir Putin.' },
],
prompt: 'nein in russland',
expectTool: 'research',
acceptCall: (calls) =>
/russland|russia|präsident|president/i.test(String(calls[0]?.arguments.query ?? '')),
accept: () => true,
},
],
arms: [{ id: 'baseline+skills', strategy: STRATEGIES.baseline, skills: catalog }],
repeats: 1,
tools: builtinTools,
})

expect(attempt?.skill).toBe('research-question')
expect(attempt?.skillReason).toBe('carried-over')
expect(attempt?.calledWell).toBe(true)

const [turns] = vi.mocked(client.generate).mock.calls[0] ?? []
expect(turns?.[0]).toEqual(
expect.objectContaining({
role: 'system',
content: expect.stringContaining('This conversation is about russische Präsident.'),
}),
)
})

it('records what the answer check found and whether it fixed it', async () => {
const [attempt] = await runEval(fakeClient([CALL, '</think>It comes to 5.', '</think>2 + 2 = 4']), {
scenarios: [arithmetic],
Expand Down
18 changes: 18 additions & 0 deletions src/eval/scenarios.ts
Original file line number Diff line number Diff line change
Expand Up @@ -557,6 +557,24 @@ export const SCENARIOS: Scenario[] = [
accept: matches(/macron/i),
online: true,
},
{
id: 'web-follow-up-correction',
category: 'web',
// *nein in Russland* used to keep the skill and skip the tool, so the
// model guessed Putin from training data — hedged, and with the wrong date.
history: [
{ role: 'user', content: 'Wer ist der russische Präsident?' },
{
role: 'assistant',
content: 'Wladimir Putin.\n\nSource: https://de.wikipedia.org/wiki/Präsident_Russlands',
},
],
prompt: 'nein in russland',
expectTool: 'research',
acceptCall: (calls) => /russland|russia|präsident|president|putin/i.test(searchQuery(calls) ?? ''),
accept: matches(/putin/i),
online: true,
},
{
id: 'no-tool-summarize-pronoun',
category: 'no-tool',
Expand Down
Loading