From 1158ae9ffd4bb1c5687f89ac0427c06481911a99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 5 Oct 2026 07:47:31 +0200 Subject: [PATCH] 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. --- .../coach-history-reach-guard.test.ts | 52 ++++++++++++++++++- .../__tests__/executor-results.test.ts | 4 ++ src/lib/ai/coach/tools/executor.ts | 16 +++++- src/lib/ai/coach/turn/model.ts | 6 ++- 4 files changed, 74 insertions(+), 4 deletions(-) diff --git a/src/lib/ai/coach/__tests__/coach-history-reach-guard.test.ts b/src/lib/ai/coach/__tests__/coach-history-reach-guard.test.ts index 231b435b4..7b0a50e26 100644 --- a/src/lib/ai/coach/__tests__/coach-history-reach-guard.test.ts +++ b/src/lib/ai/coach/__tests__/coach-history-reach-guard.test.ts @@ -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"), diff --git a/src/lib/ai/coach/results/__tests__/executor-results.test.ts b/src/lib/ai/coach/results/__tests__/executor-results.test.ts index e7d2fe1f6..b793deb6f 100644 --- a/src/lib/ai/coach/results/__tests__/executor-results.test.ts +++ b/src/lib/ai/coach/results/__tests__/executor-results.test.ts @@ -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: [ diff --git a/src/lib/ai/coach/tools/executor.ts b/src/lib/ai/coach/tools/executor.ts index 9e32436e9..b1f4f94e3 100644 --- a/src/lib/ai/coach/tools/executor.ts +++ b/src/lib/ai/coach/tools/executor.ts @@ -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; scope: CoachScope | undefined; + reach: CoachHistoryReach; prior: readonly PriorResultTurn[]; }): Promise { 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); } diff --git a/src/lib/ai/coach/turn/model.ts b/src/lib/ai/coach/turn/model.ts index 2ab0aa91b..20d98aff9 100644 --- a/src/lib/ai/coach/turn/model.ts +++ b/src/lib/ai/coach/turn/model.ts @@ -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) =>