fix(coach): keep earlier tables beyond the lookback limit out of the context

The earlier-tables block names each stored table with its window and row
count. The list was filtered by module and exclusion only, so a table
stored before the limit was set or narrowed still told the model how much
history it covered. It now answers to the same range test show_result
uses, and the history-reach guard checks both the filter and that the
call site passes the limit.
This commit is contained in:
Marc-André Bombeck committed 2026-10-05 07:47:31 +02:00
1 parent 0c1dd1e967
commit 1158ae9ffd
4 files changed
+74 -4

No files matched your search

@@ -178,7 +178,11 @@ vi.mock("@/lib/ai/coach/persistence", () => ({
}));
vi.mock("@/lib/db", () => ({ prisma: {} }));
import { executeCoachTool } from "@/lib/ai/coach/tools/executor";
import {
admittedPriorResults,
executeCoachTool,
} from "@/lib/ai/coach/tools/executor";
import { renderPriorResultRefs } from "@/lib/ai/coach/chat-request-builder";
import {
COACH_TOOL_DEFS,
COACH_TOOL_NAMES,
@@ -324,6 +328,44 @@ describe("every Coach tool honours the lookback limit", () => {
}
});
describe("earlier tables answer to the lookback limit", () => {
beforeEach(() => {
vi.useFakeTimers({ now: NOW, toFake: ["Date"] });
});
it("names no earlier table whose range lies beyond the limit", async () => {
// The context line carries the table's window and row count; an
// all-time table stored before the limit was set must not be listed.
const admitted = await admittedPriorResults({
userId: "u1",
prefs: { excludeMetrics: [] },
scope: undefined,
reach: REACH,
prior: PRIOR,
});
const refs = admitted.flatMap((turn) => turn.results.map((r) => r.ref));
expect(refs).toEqual(["r2"]);
const context = renderPriorResultRefs(admitted);
expect(context).toContain("last7days");
expect(context).not.toContain("allTime");
});
it("drops an earlier year-ago table that does not fit", async () => {
const yearAgo = {
...storedTable("r3", "last7days"),
source: { ...storedTable("r3", "last7days").source, period: "yearAgo" },
} as PriorResultTurn["results"][number];
const admitted = await admittedPriorResults({
userId: "u1",
prefs: { excludeMetrics: [] },
scope: undefined,
reach: REACH,
prior: [{ messageId: "m-y", turnIndex: 3, results: [yearAgo] }],
});
expect(admitted).toEqual([]);
});
});
// ── The source ─────────────────────────────────────────────────────────
const ROOT = join(__dirname, "../../../../..");
@@ -422,6 +464,14 @@ describe("the source hands the limit on", () => {
for (const args of calls) expect(args).not.toMatch(/\breach\b/);
});
it("passes the limit when it lists earlier tables for the model", () => {
const calls = walk(COACH_DIR).flatMap((file) =>
callArguments(readFileSync(file, "utf8"), "admittedPriorResults"),
);
expect(calls.length).toBeGreaterThan(0);
for (const args of calls) expect(args).toMatch(/\breach\b/);
});
it("passes the limit from the chat loop and nowhere from MCP", () => {
const loop = callArguments(
readFileSync(join(COACH_DIR, "tools/loop.ts"), "utf8"),
@@ -50,6 +50,7 @@ import {
type CoachToolTurnContext,
} from "@/lib/ai/coach/tools/executor";
import { createResultRefAllocator } from "../refs";
import { UNBOUNDED_REACH } from "@/lib/ai/coach/history-reach";
import { findUnverifiedCoachNumbers } from "@/lib/ai/coach/coach-prose-grounding";
const NOW = new Date("2026-09-27T10:00:00Z");
@@ -544,6 +545,7 @@ describe("admittedPriorResults", () => {
it("drops a metric the person excluded and keeps what no exclusion names", async () => {
const out = await admittedPriorResults({
userId: "u1",
reach: UNBOUNDED_REACH,
prefs: { excludeMetrics: ["steps"] },
scope: undefined,
prior,
@@ -556,6 +558,7 @@ describe("admittedPriorResults", () => {
it("keeps to the conversation's scope", async () => {
const out = await admittedPriorResults({
userId: "u1",
reach: UNBOUNDED_REACH,
prefs: { excludeMetrics: [] },
scope: { sources: ["steps"] },
prior,
@@ -572,6 +575,7 @@ describe("admittedPriorResults", () => {
resolveModuleMap.mockResolvedValue({ mood: false });
const out = await admittedPriorResults({
userId: "u1",
reach: UNBOUNDED_REACH,
prefs: { excludeMetrics: [] },
scope: undefined,
prior: [
+15 -1
View File
@@ -919,11 +919,17 @@ export function scopeAdmits(
* (`coachExclusions`, `admitCoachSources`), so a metric the person excluded
* after a table was stored is neither listed for the model nor sent to it.
* Tables of other domains (labs) answer to their module when read.
*
* The lookback limit applies the same way: a table whose range reaches past
* it (stored before the limit was set or narrowed) is not listed, because
* its line hands the model the window and the row count of history the
* Coach may no longer read. `show_result` refuses it on the same test.
*/
export async function admittedPriorResults(args: {
userId: string;
prefs: Pick<CoachPrefs, "excludeMetrics">;
scope: CoachScope | undefined;
reach: CoachHistoryReach;
prior: readonly PriorResultTurn[];
}): Promise<PriorResultTurn[]> {
if (args.prior.length === 0) return [];
@@ -943,7 +949,15 @@ export async function admittedPriorResults(args: {
return args.prior
.map((turn) => ({
...turn,
results: turn.results.filter((meta) => admits(meta.source.domain)),
results: turn.results.filter(
(meta) =>
admits(meta.source.domain) &&
tableRangeWithinReach(
meta.source.window,
meta.source.period ?? "current",
args.reach,
),
),
}))
.filter((turn) => turn.results.length > 0);
}
+4 -2
View File
@@ -222,13 +222,15 @@ export async function runTurnModel(args: {
effectiveScope,
ctx.reach,
);
// Earlier tables whose metric the person has since excluded are
// neither named for the model nor reachable through show_result. The
// Earlier tables whose metric the person has since excluded, or whose
// range lies beyond the lookback limit, are neither named for the
// model nor reachable through show_result. The
// scope is the one every tool of the turn reads under.
const priorResults = await admittedPriorResults({
userId,
prefs: ctx.coachPrefs,
scope: inventory.probeScope,
reach: ctx.reach,
prior: args.priorResults ?? [],
});
const requestWith = (tableRules: boolean) =>