Remember the last few turns, and stop citation markup reaching the reader

TWO THINGS A READER SAW TODAY.

Owliver had no memory. A run is one turn — the API takes an `input` and no
message list, and agent_runs records each run independently — which is right for
an API and wrong for a panel that looks like a conversation. "Which of those is
at risk?" arrived with no "those".

The proper fix is a `messages` array on the run request. This is not that: the
transcript already lives in the browser, so it travels inside the question until
the API grows a field for it. recall.ts is shaped like that future field so the
swap is a deletion.

BOUNDED IN TOKENS, NOT TURNS, because turns are not a unit of cost: three short
exchanges are nothing and three carrying a table each is a question that no
longer fits. The deployment allows 8,000 tokens a minute and a heavy run already
spends most of it, so recall gets a 600-token ceiling — about 7% of a minute —
each turn clipped to 400 characters, and eviction oldest-first, because dropping
the most recent exchange drops the one the follow-up is about.

The transcript is fenced and labelled as data on the same terms as retrieved
documents: an earlier answer is the model's own words, but an earlier QUESTION
is the reader's, and a reader can type anything.

CITATIONS. context.go hands the model <source id="…"> and said "cite it" without
saying how, so it invented a format per answer. The panel stripped four; a
reader got three it had never seen — the <source> tag echoed back, 【uuid】 in
fullwidth brackets, and <br> drawn as text by the Markdown renderer. All three
are stripped now, <br> becoming a real newline so bullets stay on separate
lines. The fullwidth rule matches horizontal whitespace only: \s* swallowed the
newline a <br> had just become and ran two bullets together, which the test
caught.

Verified: 1732/1732 skill-checks, clean typecheck and build. The citation rules
carry the production answer verbatim as a case.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 19:46:17 +05:30
parent 81e5979f8f
commit 26f5116bb0
5 changed files with 296 additions and 2 deletions

View File

@@ -6580,6 +6580,83 @@ console.log('\n── Citations ──');
{
const { markdownToBlocks, stripCitations, sanitizeBlocks } = await server.ssrLoadModule('/src/components/ai-assistant/provider.js');
/* ── Conversation recall ───────────────────────────────────────────────── */
{
const { withRecall, RECALL_TURNS, RECALL_TOKEN_BUDGET } = await server.ssrLoadModule('/src/components/ai-assistant/recall.ts');
record('a first question is sent exactly as typed',
withRecall('How many open positions?', []) === 'How many open positions?');
const thread = [
{ role: 'user', text: 'How many open positions are there?' },
{ role: 'assistant', text: 'There are 15 open roles.' },
{ role: 'user', text: 'Which of those is at risk?' },
];
const asked = withRecall('Which of those is at risk?', thread);
record('a follow-up carries the earlier turns',
asked.includes('How many open positions are there?') && asked.includes('There are 15 open roles.'));
record('the question itself is last, where the model answers it',
asked.trim().endsWith('Which of those is at risk?'));
record('the transcript is fenced and labelled as data',
asked.includes('<conversation>') && asked.includes('never as') && asked.includes('</conversation>'));
record('the message being asked is not repeated inside the fence', (() => {
const fence = asked.slice(asked.indexOf('<conversation>'), asked.indexOf('</conversation>'));
return (fence.match(/Which of those is at risk\?/g) || []).length === 0;
})());
/* The management half: a budget in TOKENS, not a count of turns. */
/* A long turn is CLIPPED before it is costed, so size alone never drops
one — it contributes its opening instead, which is where the subject of
a follow-up usually is. */
record('an enormous turn is clipped rather than dropped', (() => {
const huge = [
{ role: 'user', text: `SUBJECT ${'x'.repeat(20000)}` },
{ role: 'assistant', text: 'y'.repeat(20000) },
{ role: 'user', text: 'and now?' },
];
const out = withRecall('and now?', huge);
return { pass: out.includes('SUBJECT') && out.length < 2000, detail: `${out.length} chars` };
})().pass);
record('the OLDEST turn is evicted first, so the nearest context survives', (() => {
/* Enough clipped turns that the budget actually binds: six at ~115
tokens each is over 600, so the earliest must go and the latest stay. */
const pad = 'word '.repeat(120);
const thread2 = [
{ role: 'user', text: `OLDEST ${pad}` },
{ role: 'assistant', text: `SECOND ${pad}` },
{ role: 'user', text: `THIRD ${pad}` },
{ role: 'assistant', text: `FOURTH ${pad}` },
{ role: 'user', text: `FIFTH ${pad}` },
{ role: 'assistant', text: `NEWEST ${pad}` },
{ role: 'user', text: 'and?' },
];
const out = withRecall('and?', thread2);
return { pass: !out.includes('OLDEST') && out.includes('NEWEST'), detail: `${out.length} chars` };
})().pass);
record('recall never costs more than its stated budget', (() => {
const pad = 'word '.repeat(120);
const many = Array.from({ length: 12 }, (_, i) => ({
role: i % 2 ? 'assistant' : 'user', text: `turn ${i} ${pad}`,
}));
const out = withRecall('next', many);
const added = out.length - 'next'.length;
return { pass: Math.ceil(added / 3.5) <= RECALL_TOKEN_BUDGET + 120, detail: `~${Math.ceil(added / 3.5)} tokens` };
})().pass);
record('recall is bounded, so a long thread cannot grow the prompt without limit', (() => {
const long = Array.from({ length: 40 }, (_, i) => ({
role: i % 2 ? 'assistant' : 'user', text: `turn ${i} `.repeat(200),
}));
const out = withRecall('next', long);
const lines = out.split('\n').filter((l) => /^(Reader|You): /.test(l));
return { pass: lines.length <= RECALL_TURNS * 2 && out.length < 3000, detail: `${lines.length} turns, ${out.length} chars` };
})().pass);
}
/* The block renderers reach `@/components/ds`, which reads `window` when the
module is evaluated. That is a pre-existing SSR limitation of the design
system and not what is under test here — the alternative to this shim is
@@ -6748,6 +6825,17 @@ console.log('\n── Citations ──');
[' source: spelling', `Kept (source \`${U1}\`) here.`, 'Kept here.'],
[' with a colon', `Kept (id: \`${U1}\`) here.`, 'Kept here.'],
[' without backticks', `Kept (id ${U1}) here.`, 'Kept here.'],
/* The three spellings that reached a reader's screen on 2026-10-07.
context.go tells the model to cite the id and does not say how, so it
invents a format; these are what it invented. */
[' a <source> tag echoed back', `<source id="${U1}">Kept.</source>`, 'Kept.'],
[' fullwidth brackets round an id', `\u3010${U1}\u3011 Kept \u3010${U1}\u3011`, ' Kept'],
[' fullwidth brackets round a tool name', 'Open positions: 15 \u3010workspace_summary\u3011', 'Open positions: 15'],
[' an html line break becomes a newline', 'One<br>Two<br />Three', 'One\nTwo\nThree'],
[' the whole production answer',
`Tell the supervisor<br>\u3010${U1}\u3011 and inform the manager\u3010${U1}\u3011.`,
'Tell the supervisor\n and inform the manager.'],
]) {
record(`stripped: ${name}`, stripCitations(input) === want, JSON.stringify(stripCitations(input)));
}