Grade the Coach turn that went looking and found nothing

The numeric grounding guard keyed its activation off `loop.toolResults` alone.
A tool that finds nothing resolves to `{ present: false, reason: "none" }` with
no `available` payload, `runCoachToolLoop` drops that shape, and the no-tools
snapshot fallback is populated only in the non-tool branch — so the guard sat
out every turn where the record was empty, which is precisely the turn where a
fabricated figure is likeliest and does the most harm. Ask about a metric you
have never logged and an invented number was persisted and streamed verbatim,
with no withheld-figure marker to say it had never been checked.

`toolTrace` is the discriminant, because it records the pure misses the results
array drops. Tools ran and every one missed: grade the reply — an empty ledger
there is not missing information, it is the positive statement that nothing can
be cited, so `findUnverifiedCoachNumbersInLedger` takes an explicit opt-in to
grade against it rather than no-opping. The model answered without calling a
tool: still dormant, unchanged, because the tool-mode base prompt carries no
pre-computed figures and flagging a legitimately recalled one was the v1.32.1
regression. The inventory counts ride along as a widener so a plain count
restatement stays grounded.

Eliding the digits is not enough on an empty record. "Your sleep averaged […]
minutes, up from […]" still asserts a series, an average and a direction for a
metric with no readings; only the precision goes, the false claim stays. So a
turn that missed every tool and lost a figure to the guard is replaced with the
honest answer, in all six locales. Scoped so a reply whose figure reconciles
against a prior turn or an inventory count is left alone.
This commit is contained in:
Marc-André Bombeck committed 2026-08-22 16:14:54 +02:00
1 parent d49479bc64
commit 251a42bb0a
9 files changed
+185 -40

No files matched your search

+2 -1
View File
@@ -8967,7 +8967,8 @@
"outOfScope": "Ich kann nur bei den Gesundheitsdaten in deinem Log helfen — Messwerte, Medikamente, Stimmung, Trends. Fragen außerhalb dieses Themas beantworte ich nicht.",
"promptInjection": "In deiner Nachricht stehen Anweisungen, die meine Vorgaben überschreiben sollen. Ich bin der HealthLog-Coach und fasse ausschließlich deine eigenen Gesundheitsdaten zusammen — formuliere deine Frage bitte in diesem Rahmen neu.",
"conversationPoisoned": "Eine frühere Nachricht in dieser Unterhaltung enthält Anweisungen, die meine Vorgaben überschreiben sollen. Beginne bitte eine neue Unterhaltung."
}
},
"noRecordedData": "Zu deiner Frage sind bei dir keine Messwerte erfasst, also kann ich dir dazu keine Zahl nennen. Sobald du etwas eingetragen hast, frag mich noch einmal — dann rechne ich mit deinen eigenen Werten."
},
"safety": {
"floor": {
+2 -1
View File
@@ -8967,7 +8967,8 @@
"outOfScope": "I can only help with the health metrics in your log — measurements, medications, mood, trends. I cannot answer questions outside that scope.",
"promptInjection": "I noticed wording that tries to override my instructions. I am the HealthLog Coach and only summarise your own health data — please rephrase your question in those terms.",
"conversationPoisoned": "An earlier message in this conversation contains wording that overrides my instructions. Please start a new conversation."
}
},
"noRecordedData": "I don't have any recorded readings for what you asked about, so there is nothing I can give you a figure from. Once you log some, ask me again and I'll work from your own numbers."
},
"safety": {
"floor": {
+2 -1
View File
@@ -8967,7 +8967,8 @@
"outOfScope": "Solo puedo ayudarte con los datos de salud de tu registro: mediciones, medicamentos, estado de ánimo, tendencias. No puedo responder preguntas fuera de ese ámbito.",
"promptInjection": "He detectado instrucciones que intentan anular mis directrices. Soy el Coach de HealthLog y solo resumo tus propios datos de salud; reformula tu pregunta en esos términos, por favor.",
"conversationPoisoned": "Un mensaje anterior de esta conversación contiene instrucciones que intentan anular mis directrices. Empieza una conversación nueva, por favor."
}
},
"noRecordedData": "No tienes ninguna medición registrada sobre lo que preguntas, así que no puedo darte ninguna cifra. Cuando registres algo, vuelve a preguntarme y trabajaré con tus propios datos."
},
"safety": {
"floor": {
+2 -1
View File
@@ -8967,7 +8967,8 @@
"outOfScope": "Je peux uniquement t'aider avec les données de santé de ton journal : mesures, médicaments, humeur, tendances. Je ne réponds pas aux questions en dehors de ce cadre.",
"promptInjection": "J'ai repéré des instructions qui cherchent à contourner mes consignes. Je suis le Coach HealthLog et je résume uniquement tes propres données de santé — reformule ta question dans ce cadre, s'il te plaît.",
"conversationPoisoned": "Un message précédent de cette conversation contient des instructions qui cherchent à contourner mes consignes. Démarre une nouvelle conversation, s'il te plaît."
}
},
"noRecordedData": "Aucune mesure n'est enregistrée pour ce que tu demandes, je ne peux donc te donner aucun chiffre. Dès que tu en auras saisi, repose-moi la question et je partirai de tes propres valeurs."
},
"safety": {
"floor": {
+2 -1
View File
@@ -8967,7 +8967,8 @@
"outOfScope": "Posso aiutarti solo con i dati di salute del tuo registro: misurazioni, farmaci, umore, tendenze. Non rispondo a domande al di fuori di questo ambito.",
"promptInjection": "Ho notato istruzioni che cercano di aggirare le mie direttive. Sono il Coach di HealthLog e riassumo soltanto i tuoi dati di salute — riformula la domanda in questi termini, per favore.",
"conversationPoisoned": "Un messaggio precedente di questa conversazione contiene istruzioni che cercano di aggirare le mie direttive. Inizia una nuova conversazione, per favore."
}
},
"noRecordedData": "Non hai misurazioni registrate su quello che chiedi, quindi non posso darti alcun valore. Appena ne inserisci qualcuna, richiedimelo e userò i tuoi dati."
},
"safety": {
"floor": {
+2 -1
View File
@@ -8967,7 +8967,8 @@
"outOfScope": "Mogę pomóc wyłącznie z danymi zdrowotnymi z Twojego dziennika: pomiarami, lekami, nastrojem, trendami. Na pytania spoza tego zakresu nie odpowiadam.",
"promptInjection": "Zauważyłem polecenia, które próbują obejść moje wytyczne. Jestem Coachem HealthLog i podsumowuję wyłącznie Twoje własne dane zdrowotne — sformułuj pytanie w tych ramach.",
"conversationPoisoned": "Wcześniejsza wiadomość w tej rozmowie zawiera polecenia, które próbują obejść moje wytyczne. Rozpocznij nową rozmowę."
}
},
"noRecordedData": "Nie masz zapisanych żadnych pomiarów dla tego, o co pytasz, więc nie mogę podać Ci żadnej liczby. Gdy coś zapiszesz, zapytaj ponownie — wtedy oprę się na Twoich własnych wartościach."
},
"safety": {
"floor": {
@@ -1,30 +1,26 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
/**
* CHARACTERISATION — when the Coach's numeric grounding guard is armed, and
* when it is not.
* REGRESSION — when the Coach's numeric grounding guard is armed, and when it
* deliberately is not.
*
* The guard rewrites any figure in the reply that the turn's Grounding Ledger
* cannot account for. It runs only when `activatingPayloads` is non-empty, and
* on the tool path that array comes solely from `loop.toolResults`. A tool that
* found nothing at all resolves to `{ present: false, reason: "none" }` with no
* `available` payload, and `loop.ts` drops that shape from `toolResults`
* (pinned in `tools-loop-miss-payloads.test.ts`). The no-tools snapshot
* fallback is populated only in the non-tool-mode branch, so on the tool path
* there is nothing to fall back to.
* cannot account for. Its activation used to key solely off `loop.toolResults`,
* and a tool that found nothing resolves to `{ present: false, reason: "none" }`
* with no `available` payload, which `loop.ts` drops (pinned in
* `tools-loop-miss-payloads.test.ts`). So the guard disarmed itself exactly
* when the record was empty, and a fabricated figure shipped verbatim with no
* withheld-figure marker.
*
* The consequence, pinned below: on a tool-mode turn where every tool missed —
* i.e. exactly when the user has no data for what they asked about — a figure
* the model invented is streamed and persisted verbatim, and the reply carries
* no `unverifiedFigures` marker, so it reads to the user as fully checked.
* `toolTrace` now discriminates the two cases the old condition conflated:
*
* These tests assert the CURRENT behaviour, not the desired one. If the
* activation gate is widened so the guard covers the all-missed turn, they will
* fail — that failure is the fix landing, and the assertions should be flipped
* to the safe expectation at that point.
*
* The first test is the control: with one present tool result the guard is
* armed and the same invented figure is stripped.
* - tools RAN and every one missed -> grade. An empty ledger is the finding,
* not a reason to skip, so every magnitude is unreconciled. A reply that
* loses a figure this way is replaced with the honest "nothing recorded"
* answer rather than shipping elided prose that still asserts a series.
* - the model called NO tool -> stay dormant. This is the v1.32.1
* contract: the tool-mode base prompt deliberately carries no pre-computed
* figures, and flagging a legitimately recalled one was a real regression.
*/
const SNAPSHOT_JSON = '{"bp":{"aggregate":{"mean":128}}}';
@@ -301,6 +297,27 @@ describe("coach chat — grounding-guard activation", () => {
});
});
/** Drive the loop with an explicit trace so a MISS is modelled faithfully. */
function stubMissedTurn(prose: string): void {
parseKeyValuesSentinel.mockReturnValue({
prose,
keyValues: [],
malformed: false,
malformedEntries: [],
});
parseSuggestReminder.mockReturnValue({ prose });
runCoachToolLoop.mockImplementation(async () => ({
result: { content: prose, tokensUsed: 80, model: "m" },
workingProviderType: "anthropic",
totalTokens: 80,
rounds: 1,
// The tool ran and found nothing; `loop.ts` drops the pure miss from
// `toolResults` but the trace still records the call.
toolTrace: [{ name: "get_sleep", present: false }],
toolResults: [],
}));
}
it("CONTROL — one present tool result arms the guard: the invented figure is stripped", async () => {
stubReply("Your sleep averaged 432 minutes last week.", [
{ present: true, data: { metric: "bp", aggregate: { avgSys30: 128 } } },
@@ -314,22 +331,58 @@ describe("coach chat — grounding-guard activation", () => {
expect(assistantProvenance()?.unverifiedFigures).toBe(1);
});
it("every tool missed: the guard stays dormant and the invented figure survives", async () => {
// A pure miss never reaches `toolResults` (see tools-loop-miss-payloads),
// so this is what the route sees when the record holds nothing.
stubReply("Your sleep averaged 432 minutes last week.", []);
it("every tool missed: the fabricated figure is caught and the turn is replaced", async () => {
stubMissedTurn("Your sleep averaged 432 minutes last week.");
await post(chatReq({ conversationId: "c1", message: "how did I sleep?" }));
await sse.done;
expect(assistantContent()).toBe(
"Your sleep averaged 432 minutes last week.",
);
// No withheld-figure notice either, so the reply reads as verified.
const content = assistantContent();
expect(content).not.toContain("432");
// Not the elided prose either — the sentence asserted a series the record
// does not hold, so the whole turn is replaced with the honest answer.
expect(content).not.toContain("[…]");
expect(content).toContain("recorded readings");
// The replacement copy says it in words, so the count-based notice is off.
expect(assistantProvenance()?.unverifiedFigures).toBeUndefined();
});
it("the model called no tool at all: the guard stays dormant", async () => {
it("every tool missed but the figure is grounded by a prior turn: left alone", async () => {
// Turn 1 fetched systolic 128 and persisted it as a tool figure. This turn
// every tool misses, but recalling 128 still reconciles, so nothing is
// stripped and the reply is NOT replaced.
fetchConversationWithMessages.mockResolvedValue({
id: "c1",
attachmentCount: 0,
summary: null,
messages: [
{ role: "user", content: "How is my BP?" },
{
role: "assistant",
content: "Your systolic averaged 128.",
metricSource: { groundedFigures: [128] },
},
],
});
stubMissedTurn(
"No sleep readings yet. Your systolic 128 average is unrelated to that.",
);
await post(chatReq({ conversationId: "c1", message: "and my sleep?" }));
await sse.done;
const content = assistantContent();
expect(content).toContain("128");
expect(content).not.toContain("[…]");
expect(content).not.toContain("recorded readings");
});
it("the model called no tool at all: the guard stays dormant (v1.32.1)", async () => {
// Deliberately unchanged behaviour. The tool-mode base prompt carries no
// pre-computed figures, so a number here came from the transcript or the
// model's own recall, and grading it off the counts-only inventory flagged
// legitimate figures as ungrounded. `toolTrace` is empty, so the widened
// activation above does not reach this case.
const prose = "Your resting heart rate has been averaging 58 bpm.";
parseKeyValuesSentinel.mockReturnValue({
prose,
+76 -2
View File
@@ -639,6 +639,13 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
// delivered this turn (`includeFullSnapshot`); on a cheap follow-up the block
// was not re-sent, so there is no fresh authoritative set to grade against.
let noToolsSnapshotPayloads: unknown[] = [];
// The DATA INVENTORY manifest (per-domain sample counts) that rode this
// turn's tool-mode prompt. Hoisted out of the tool branch because the
// all-missed activation below needs it as a WIDENER: the counts were in
// front of the model, so a plain count restatement ("you've logged 42 BP
// readings") must stay grounded even on a turn whose every tool missed.
// Never an ACTIVATOR on its own — see the v1.32.1 note below.
let inventoryPayloads: unknown[] = [];
let totalTokensSpent: number;
// v1.21.0 (F3) — cached-input tokens to subtract at reconcile (prompt-cached
// input the user did not re-pay for must not be billed to the daily meter).
@@ -705,6 +712,7 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
// out-of-window aggregate rule 3 lets the model cite. Ground it.
...(loop.toolResults ?? []).map((r) => r.data ?? r.available),
];
inventoryPayloads = [inventory.entries];
toolResultPayloads =
presentToolPayloads.length > 0
? [...presentToolPayloads, inventory.entries]
@@ -993,17 +1001,43 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
// active grading — they never ACTIVATE it, so a snapshot figure the model
// cited on a no-tool turn is still left alone (the v1.32.1 regression guard
// holds). Assistant prose is never a ledger source (D3).
//
// The turn that CALLED tools and got nothing back is graded too. It used to
// be the one turn the verifier sat out: a pure miss carries no `data` and no
// `available`, `runCoachToolLoop` drops that shape from `toolResults`, and
// the no-tools snapshot fallback below is populated only in the non-tool
// branch — so `activatingPayloads` came out empty and every figure in the
// reply shipped unchecked. That is backwards. A turn whose every tool
// reported `{ present: false }` is not an absence of evidence about the
// reply, it is evidence that the record holds nothing to cite, which is
// exactly when a fabricated figure is both likeliest and most harmful.
//
// `toolTrace` is the discriminant, because it records every tool that ran
// INCLUDING the pure misses. It separates the two cases the old condition
// conflated: tools ran and found nothing (grade — the model was told the
// record is empty and answered with numbers anyway), versus the model
// answered without calling a tool at all (stay dormant — the v1.32.1
// regression, where the base prompt deliberately carries no pre-computed
// figures and flagging a legitimately recalled one was a real defect).
const missedEveryTool =
toolMode && toolTrace.length > 0 && toolResultPayloads.length === 0;
const activatingPayloads =
toolResultPayloads.length > 0
? toolResultPayloads
: noToolsSnapshotPayloads;
: missedEveryTool
? inventoryPayloads
: noToolsSnapshotPayloads;
let groundedFigures: number[] = [];
// v1.32.14 — count of figures withheld from THIS reply (each rewritten to the
// elision mark). Hoisted out of the guard block so it can ride the provenance
// envelope and drive the quiet per-message notice. Stays 0 on a blocked turn
// (the guard is skipped, its fallback prose carries no figures).
let unverifiedStripped = 0;
if (activatingPayloads.length > 0) {
// True when the guard stripped a figure on a turn whose every tool missed —
// i.e. the model put a number on a record that holds none. Drives the
// honest-replacement below.
let fabricatedOnEmptyRecord = false;
if (activatingPayloads.length > 0 || missedEveryTool) {
const ledger = buildGroundingLedger({
toolPayloads: activatingPayloads,
priorToolFigures,
@@ -1026,6 +1060,9 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
replyText,
ledger,
locale,
// On an all-missed turn an EMPTY ledger is the finding, not a reason
// to skip: nothing was retrievable, so nothing can reconcile.
{ gradeAgainstEmptyLedger: missedEveryTool },
);
if (unverified.length > 0) {
const { prose: corrected, stripped } = stripUnverifiedNumbers(
@@ -1034,6 +1071,7 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
);
replyText = corrected;
unverifiedStripped = stripped;
fabricatedOnEmptyRecord = missedEveryTool && stripped > 0;
annotate({
action: { name: "coach.prose.number_unverified" },
meta: {
@@ -1048,6 +1086,42 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
}
}
// A stripped reply on an empty record is not worth sending. Eliding the
// digits leaves the sentences that carried them — "your sleep averaged […]
// minutes, up from […]" still asserts a series, an average and a direction
// for a metric the tools just reported has no readings at all. The false
// claim survives the strip; only its precision goes. So replace the turn
// with the honest answer instead of shipping the elided mush.
//
// Scoped tightly: this fires only when every tool missed AND the guard
// actually stripped something. A reply that cited a legitimately grounded
// figure (a prior turn's tool result, an inventory count) reconciles
// against the ledger, strips nothing, and is left alone.
//
// REPLACE, not withhold — the same policy the outbound screen uses on this
// surface, for the same reason: the user is waiting on a synchronous answer
// and silence reads as a failure.
//
// Accepted trade-off: a reply mixing one grounded recall with one
// fabrication is replaced wholesale, losing the good half. That is the
// right way round — the alternative leaves an invented figure's sentence
// on screen.
if (fabricatedOnEmptyRecord) {
replyText = getServerTranslator(locale).t("coach.noRecordedData");
// The reply now carries no figures at all, so the withheld-figure notice
// would be describing prose the reader can no longer see. The replacement
// copy states the same thing in plain words.
unverifiedStripped = 0;
annotate({
action: { name: "coach.prose.empty_record_replaced" },
meta: { promptVersion: PROMPT_VERSION },
});
await auditLog("insights.coach.empty_record_replaced", {
userId,
details: { conversationId: workingConversationId },
});
}
// v1.21.0 (NEW-C C-3) — Learn-link post-filter. The prompt instructs the
// model to only link a published `/learn/<slug>`, but that is guidance, not
// enforcement: a fabricated `/learn/<invented-slug>` would otherwise ship as
+15 -3
View File
@@ -739,11 +739,23 @@ export function findUnverifiedCoachNumbersInLedger(
prose: string,
ledger: ReadonlyArray<LedgerEntry>,
locale: Locale = "en",
opts: { gradeAgainstEmptyLedger?: boolean } = {},
): UnverifiedCoachNumber[] {
if (!prose) return [];
// No authoritative figures (a qualitative turn / no-tools path) — nothing to
// grade against. The prompt-level grounding rule remains the backstop.
if (ledger.length === 0) return [];
// An empty ledger is ambiguous on its own, so the caller resolves it.
//
// Default (`false`): no authoritative figures were delivered — a qualitative
// turn, or the no-tools path on a cheap follow-up. Nothing to grade against,
// and the prompt-level grounding rule remains the backstop.
//
// `gradeAgainstEmptyLedger: true`: the caller KNOWS this turn went looking
// and came back with nothing — every retrieval tool it called reported
// `{ present: false }`. Then an empty ledger is not "no information", it is
// the positive statement that the record holds nothing to cite, so every
// magnitude in the reply is unreconciled by construction and must be graded
// as such. Only the chat route passes this; the fenced-chat and eval callers
// keep the default.
if (ledger.length === 0 && !opts.gradeAgainstEmptyLedger) return [];
const spans = sentenceSpans(prose);
const sentenceAt = (index: number): string => {